xkat-cli 2.0.17 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,265 @@
1
+ # Changelog
2
+
3
+ ## 2.7.1
4
+
5
+ - **"가상 머신이 돌고 있나" 를 두 곳이 다르게 답하던 문제.** 에이전트가
6
+ `pgrep -f qemu-system` 으로 직접 세고 있었는데, 그 검사를 감싼 `sh -c` 의
7
+ 명령줄에 같은 문자열이 들어 있어 늘 "돌고 있다" 가 나왔다. 스크립트의
8
+ `xkat-lab-vm status` 와 답이 갈렸고, 갈린 줄도 몰랐다.
9
+
10
+ 판정은 스크립트 하나가 갖는다. `xkat-lab-vm status --porcelain` 이
11
+ `running=` · `installed=` · `media=` 를 내보내고 에이전트는 그것을 읽는다.
12
+ 밖에서 흉내내지 않으면 갈릴 수 없다.
13
+
14
+ - **승격 실행이 남긴 root 소유 가상 디스크.** KVM 을 쓰려고 qemu 를 승격해서
15
+ 띄우면 그 프로세스가 만진 파일이 root 소유로 남을 수 있다. 학습자가 나중에
16
+ 그 파일을 다룰 때 막히므로, 시작 전에 쓰기 권한을 확인하고 되돌린다.
17
+
18
+ ## 2.7.0
19
+
20
+ - **`xkat-lab-vm reinstall` 이 초기화만 하고 부팅하지 않는다.** 부팅은 강의가
21
+ 가르치는 단계다(1강 끝의 `xkat-lab-vm start`). 여기서 켜 버리면 학습자가 그
22
+ 단계에 닿기도 전에 설치 화면이 떠 있고, 그 뒤로 가이드와 화면이 어긋난 채
23
+ 진행된다. 초기화까지만 하고 순서는 강의에 돌려준다.
24
+
25
+ - **가속을 쓸 수 있는데도 소프트웨어 에뮬레이션으로 돌던 문제.** 호스트에
26
+ `/dev/kvm` 이 있고 접근 권한도 있는데 랩은 TCG 로 부팅하고 있었다. rootless
27
+ podman 에서 컨테이너의 `ubuntu` 는 호스트의 subuid 로 매핑되므로, 호스트가
28
+ 자기 사용자에게만 열어 둔 장치에 닿지 못한다(컨테이너 root 는 호스트 사용자
29
+ 자신이라 접근된다). 접근이 막혔을 때 **qemu 프로세스만** 승격해서 띄운다.
30
+ 디스크는 이미 학습자 소유로 만들어져 있으므로 파일 소유권은 그대로다.
31
+
32
+ 데스크톱 설치에서 이 차이는 몇 분과 십수 분의 차이다. 학습자에게 느린 부팅은
33
+ "먹통"과 구분되지 않는다.
34
+
35
+ - `xkat-lab-vm start` 가 부팅 중 화면이 검은 것이 정상이라고 알린다.
36
+
37
+ ## 2.6.0
38
+
39
+ - **가상 머신 제어를 `lab_vm` 한 곳으로.** `POST /lab/vm { profile, action }` 로
40
+ start·stop·reinstall 을 지시한다. 어느 컨테이너에서 무엇을 실행할지는 이
41
+ 모듈만 알고, 밖에서는 부르기만 한다. **언제나 그 프로필의 컨테이너 하나만**
42
+ 건드린다 — 랩이 여럿 떠 있을 때 학습자가 보고 있지도 않은 강의의 가상 머신을
43
+ 멈추는 일이 없어야 한다.
44
+
45
+ - **`xkat-cli down` 이 랩 가상 머신도 멈춘다.** QEMU 는 `setsid` 로 분리돼 있어
46
+ 아무도 멈추라고 하지 않으면 계속 돌았다. 브라우저를 닫아도 4GB RAM 과 2 vCPU
47
+ 가 물려 있던 이유다. 에이전트를 내린다는 것은 이 컴퓨터에서 실습을 멈춘다는
48
+ 뜻이므로 함께 멈춘다. 컨테이너는 그대로 두므로 작업물은 보존된다.
49
+
50
+ ## 2.5.0
51
+
52
+ - **가상 머신이 무엇을 하고 있는지 밖에서 물을 수 있다.** `/lab/status` 가
53
+ `vm: { installed, media, running }` 을 보고한다. 지금까지 이 상태는 컨테이너
54
+ 안의 마커 파일에만 있었고, 그래서 가이드와 QEMU 는 서로를 모른 채 각자
55
+ 진행했다 — 설치 강의를 열었는데 이미 설치를 마친 디스크로 부팅되면 학습자는
56
+ 로그인 화면 앞에서 설치 설명을 읽었다. 강의가 통째로 무의미해지는데 아무도
57
+ 알아차릴 수 없었다. 새 모듈 `src/lab_vm.rs` 가 이 상태를 소유한다.
58
+
59
+ - **`xkat-lab-vm start` 가 무엇으로 부팅하는지 말한다.** 설치된 디스크인지
60
+ 설치 이미지인지, 그리고 이미 실행 중이면 지금 화면이 그 세션이라는 것까지.
61
+ 예전에는 조용히 골라서 학습자가 화면과 강의가 다른 이유를 알 수 없었다.
62
+
63
+ - **`xkat-lab-vm reinstall` 추가.** 마커와 가상 디스크를 지우고 설치 이미지로
64
+ 다시 부팅한다. 파괴적이라 자동으로는 절대 하지 않는다 — 학습자가 명시적으로
65
+ 고른 경우에만 실행된다. `status` 도 설치 여부와 이미지 유무를 함께 보고한다.
66
+
67
+ ## 2.4.0
68
+
69
+ - **실습 프로젝트 폴더를 플랫폼이 만든다.** 강의마다 규약을 정하게 두었더니
70
+ `~/rpi-lab`, `~/fedora-lab`, `~/xkat-practice/…`, 그리고 아예 만들지 않고
71
+ 작업 공간 루트에 파일을 떨어뜨리던 설치 강의까지 네 갈래로 갈렸다.
72
+
73
+ 프로필이 `workdir` 로 이름을 정하고(`ubuntu_qemu` → `ubuntu-lab`), 컨테이너를
74
+ 켤 때 플랫폼이 만든다. 지정하지 않으면 프로필 이름에서 만들어 내고, `""` 면
75
+ 만들지 않는다 — 루트에서 작업하는 강의를 위한 옵션이고, 터미널 전용
76
+ 프로필(`std-bash-*`)이 그렇게 두었다.
77
+
78
+ 학습자의 터미널은 프로젝트 폴더 안이 아니라 **작업 공간 루트(`~/lab`)** 에서
79
+ 시작한다. 폴더를 만들고 들어가는 것은 강의가 가르칠 내용이라, 이미 그 안에서
80
+ 시작하면 `mkdir` 도 `cd` 도 시늉이 되어 학습자가 자기가 무엇을 했는지 알 수
81
+ 없다. 폴더 자체는 플랫폼이 만들어 두므로 강의가 그 단계를 어떻게 쓰든
82
+ `xkat-lab-vm` 은 설치 이미지를 찾을 수 있다.
83
+
84
+ 경로·사용자·소유권·폴더 규약이 새 모듈 `src/lab_workspace.rs` 한 곳에 모였다.
85
+ `xkat-lab-vm` 도 `LAB_DIR` 을 이 모듈이 쓴 설정에서 받는다.
86
+
87
+ - **설치 이미지는 프로젝트 폴더에서만 찾는다.** `xkat-lab-vm` 은
88
+ `/opt/xkat/iso`(공유 캐시)와 `$LAB_DIR/*/<파일명>`(프로젝트 폴더) 두 곳만
89
+ 본다. 작업 공간 루트는 더 이상 보지 않는다 — 예전 안내로 이미 받아 둔 파일
90
+ 하나를 위해 예외를 남기면 규칙이 선택 사항이 되고, 그러면 설치 이미지가
91
+ 어디 있는지 아무도 말할 수 없게 된다. 옛 위치에 파일이 있다면 프로젝트
92
+ 폴더로 옮기면 된다.
93
+
94
+ - **학습자가 자기 작업 폴더에 쓸 수 없던 문제.** 마운트에 붙어 있던 podman `U`
95
+ 플래그가 컨테이너를 켤 때마다 볼륨을 컨테이너 사용자(root)로 재귀 chown 해서,
96
+ 생성 시점에 맞춰 둔 소유권을 매번 되돌리고 있었다. 학습자 대면 명령은 전부
97
+ `ubuntu` 로 실행되므로 `mkdir` 조차 막혔고, 15GB 트리를 부팅마다 훑는 비용도
98
+ 함께 치렀다. `U` 를 떼고, 소유자가 실제로 어긋났을 때만 한 번 바로잡는다.
99
+
100
+ ## 2.3.0
101
+
102
+ - **랩 호스트 포트를 컨테이너마다 배정한다.** 지금까지 모든 랩이 호스트의
103
+ `6080`(noVNC)과 `5022`(SSH)를 고정으로 공개했다. 두 컨테이너가 동시에 뜨면
104
+ 바인드에 실패하므로 애초에 동시 실행이 불가능했고, 1-Active 정책이 그 한계를
105
+ 가리고 있었다. 이제 생성 시점에 빈 포트를 고른다(`6080~6099` / `5022~5041`,
106
+ 범위의 첫 값을 우선). 랩을 하나만 쓰면 예전과 같은 매핑이다.
107
+
108
+ 포트 번호를 아는 곳은 새 모듈 `src/lab_ports.rs` 하나다. 배정값을 기억하지
109
+ 않고 `container port` 로 **매번 실제 매핑을 읽는다** — 매핑은 컨테이너 생성
110
+ 시점에 확정되므로, 구버전이 만든 컨테이너도 자기 포트를 정확히 보고한다.
111
+
112
+ - **`/lab/status` 가 `vnc_port` · `ssh_port` 를 보고한다.** 프론트엔드가 포트를
113
+ 가정하지 않고 이 값을 쓴다. 구버전 에이전트는 이 필드가 없어 프론트가 6080
114
+ 기본값으로 떨어진다 — 랩이 하나면 그대로 동작하지만, 다중 랩은 이 버전이
115
+ 필요하다.
116
+
117
+ - **`/vnc-proxy` 가 요청된 포트에서 에셋을 가져온다.** 클라이언트가 웹소켓을
118
+ 붙일 `port=` 값을 프록시도 그대로 쓰므로 둘이 어긋날 수 없다. 랩이 공개할 수
119
+ 있는 범위 밖의 포트는 거부한다 — 임의의 로컬 포트를 대신 열어 주지 않는다.
120
+
121
+ - **컨테이너 엔진 표기를 Podman 으로 일원화.** 엔진은 처음부터 podman 하나였고
122
+ (`container_bin()` 은 podman 만 찾고, 자동 설치는 `RedHat.Podman` 을 깐다)
123
+ 문구만 Docker 를 함께 지원하는 것처럼 말하고 있었다. Windows 셸 PATH 에
124
+ Docker Desktop 경로를 넣던 줄과 `xkat-cli lab docker …` 인자 흡수를 제거했다.
125
+
126
+ ## 2.2.11
127
+
128
+ - **`/vnc-proxy` 가 자기 자신의 요청을 거부하던 문제.** noVNC 의 `vnc.html` 은
129
+ 자기 코드(`app/ui.js`)를 ES 모듈로 불러오는데, 모듈 스크립트는 항상 CORS 모드라
130
+ 브라우저가 iframe 문서의 오리진(= 이 에이전트)을 실어 보낸다. 그 오리진이
131
+ 허용 목록에 없어 `origin_guard` 가 403 을 돌려주었고, noVNC 는 자기 코드를
132
+ 못 받아 데스크톱이 끝내 연결되지 않았다.
133
+
134
+ 에이전트가 실제로 바인드한 포트를 기록해 허용 목록에 자기 오리진을 넣는다.
135
+ CORS 레이어도 목록을 고정하지 않고 요청마다 판단하도록 바꿨다 — 라우터를
136
+ 만드는 시점에는 포트(38000..=38050 자동 이동)가 아직 정해지지 않는다.
137
+
138
+ ## 2.2.10
139
+
140
+ - **라즈베리파이 랩이 ARM64 컨테이너를 요구하지 않는다.** `profiles.json` 의
141
+ `raspberry_pi` 에서 `platform: linux/arm64` 와 arm64v8 이미지를 걷어냈다. 이
142
+ 강의는 x86 컨테이너 안에 ARM 에뮬레이션 툴체인을 깔고 QEMU 로 라즈베리파이 OS
143
+ 이미지를 부팅하는 방식이라, binfmt 등록이 없어도 동작해야 한다.
144
+
145
+ 2.2.9 이하 바이너리는 옛 정의를 갖고 있어 `Profile 'raspberry_pi' needs
146
+ linux/arm64 emulation` 으로 실패한다. **프로필 정의는 바이너리에 컴파일 타임에
147
+ 박히므로, profiles.json 을 고치면 반드시 버전을 올려야 한다** — 그러지 않으면
148
+ 구버전이 도는지 알 방법이 없다.
149
+
150
+ ## Unreleased — 2.2.x
151
+
152
+ > **The npm registry still serves 2.0.17** (latest published; the newest git tag
153
+ > is `agent-v2.0.9`, so 2.0.10–2.0.17 were published outside the tag workflow).
154
+ > The whole 2.1.x/2.2.x line is unreleased: the release workflow had been broken
155
+ > since the `xkat-agent` → `xkat-cli` rename. It is fixed but not yet exercised —
156
+ > see `.github/workflows/release-agent.yml`.
157
+
158
+ ### Behavior changes
159
+
160
+ These differ from 2.0.17 and are worth knowing before upgrading.
161
+
162
+ - **`down` no longer kills everything on ports 38000..38050.** It signals only
163
+ processes it can verify are xkat agents; anything else holding a port in the
164
+ range is left running and reported. Previously `fuser -k` (and an equivalent
165
+ PowerShell pipeline) took unrelated development servers down with it.
166
+ - **An unrecognised command no longer starts the agent.** Starting requires a
167
+ bare `xkat-cli`, `up`, or `start`; anything else prints help and exits 1. Before,
168
+ `xkat-cli --version` or a typo silently launched a daemon.
169
+ - **`lab reset` and `lab delete` keep learner data.** Data lives in a host volume
170
+ at `~/.xkat/labs/<profile>`, so rebuilding a container no longer destroys an
171
+ installed system. Use `lab purge <profile>` to remove data as well.
172
+ - **Lab containers are named per profile, and aliases are normalized.** `ubuntu`
173
+ and `ubuntu_qemu` resolve to the same container instead of provisioning two.
174
+ - **`/lab/exec` has no host fallback.** A lecture command that cannot reach its
175
+ container now fails with a clear error instead of running on the host machine.
176
+ - **File watching is limited to the practice folder** (`xkat-practice`, override
177
+ with `XKAT_WATCH_DIR`). The agent used to watch its whole working directory and
178
+ stream absolute paths to the browser.
179
+ - **Install media is never downloaded during provisioning.** Creating a lab used
180
+ to block on a multi-GB fetch. Lectures walk the learner through the download;
181
+ `lab media <profile>` pre-stages it for classroom or offline use.
182
+
183
+ ### Added
184
+
185
+ - `xkat-cli status --json` and `xkat-cli lab status --json` — machine-readable
186
+ status for the desktop app and scripts. The desktop status bar used to grep the
187
+ human summary for words like "active", which silently stopped matching when the
188
+ wording changed.
189
+
190
+ - `xkat-cli token`, `lab media`, `lab purge`, `version` / `--version` / `-V`.
191
+ - `profiles.json` — the single source of truth for how a lab is provisioned
192
+ (base image, CPU platform, devices, VM definition, install media). Adding an OS
193
+ is a config entry, not a code change.
194
+ - `xkat-lab-vm` control script, installed into VM-backed lab containers:
195
+ `start`, `boot-disk`, `stop`, `status`. Detects KVM and falls back to software
196
+ emulation, boots from install media until the lab is marked installed and from
197
+ disk afterwards, and detaches qemu/websockify so they outlive the exec session.
198
+ - Opt-in pairing token (`XKAT_REQUIRE_TOKEN=1`), stored owner-readable only at
199
+ `~/.xkat/token`.
200
+ - Foreign-architecture labs pass `--platform`, with a preflight that reports
201
+ missing binfmt handlers instead of failing later with "exec format error".
202
+ - Install-media download progress reported through `/lab/status`.
203
+
204
+ ### Fixed
205
+
206
+ - **Release pipeline.** Artifact names, R2 upload paths and the checksum manifest
207
+ keys disagreed, so tagged releases failed to build and, had they built, the
208
+ client would have requested a name that was never uploaded.
209
+ - **Binary integrity verification never ran.** The manifest was keyed
210
+ `xkat-agent-*` while the installer looked up `xkat-cli-*`, so every platform
211
+ silently skipped the SHA-256 check the README promised. Installation now fails
212
+ rather than installing an unverified binary.
213
+ - **Interrupted downloads were cached as usable files** — both the installer and
214
+ the install-media fetcher now write to a `.part` file and rename only after
215
+ verification.
216
+ - **Package installs could hang forever** on an interactive `debconf` prompt
217
+ (`tzdata` asking for a timezone). Blocked at three levels: the install commands,
218
+ the container's debconf frontend, and the exec session environment.
219
+ - Setup reported success it had not verified: the "verify" step printed messages
220
+ without running anything, and the install step never blocked the pipeline.
221
+ Completion is now gated on an exit-code sentinel and a real probe.
222
+ - Lab profile resolution: the lecture's profile is used instead of a substring
223
+ guess at `techStack`, a DB-joined profile is read by `slug` rather than its
224
+ cuid, and every consumer shares one fallback.
225
+ - The agent identified itself by "any HTTP 200 on the port", which combined with
226
+ the port-range kill above. It now serves and checks an explicit marker.
227
+ - `agent.json` is parsed as JSON (a hand-rolled parser corrupted Windows paths),
228
+ the recorded port is the one actually bound, and `agent.log` rotates at 5MB.
229
+ - A VNC proxy module that listened on `0.0.0.0:8444` and forwarded to itself.
230
+
231
+ ### Security
232
+
233
+ - REST endpoints enforce the same origin allowlist as the WebSocket, and CORS is
234
+ no longer permissive (`XKAT_ALLOWED_ORIGINS` extends it).
235
+ - Session modes are parsed explicitly (`lab:<profile>`, `host`,
236
+ `ssh:<user>:<password>`). An unparseable token is refused; it used to fall
237
+ through to a shell on the user's machine.
238
+ - Host-shell grants are logged with their origin.
239
+ - The VNC server binds `127.0.0.1` only, and the KasmVNC client URL no longer
240
+ carries hardcoded credentials.
241
+
242
+ ### Desktop app
243
+
244
+ - `apps/desktop` looked for a binary named `xkat-agent` or `xkat` and so never
245
+ found the renamed CLI. Where a pre-rename leftover existed (`~/.cargo/bin`, an
246
+ old `target/`) it ran that instead — a build without any of the fixes above.
247
+ It now resolves `xkat-cli` only, prefers the more recently built monorepo
248
+ profile, and reports a leftover instead of running it.
249
+ - The `Tools` menu gained `CLI: Version` and `Agent: Pairing Token`, and the
250
+ lab entries say which ones keep the learner's saved work.
251
+ - **The desktop app starts this agent on launch** — it checks first and only
252
+ starts one when none is serving, so an agent you already had running (and any
253
+ terminal attached to it) is left alone.
254
+ - **The desktop app now ships this agent.** It is bundled as a Tauri sidecar, so
255
+ installing the app is enough — no separate `npm install -g xkat-cli` — and the
256
+ bundled copy outranks whatever is on `PATH`.
257
+
258
+ ### Desktop labs
259
+
260
+ - The guest display follows the panel: QEMU runs with a virtio display, which
261
+ accepts client-initiated resize, and the viewer uses noVNC's `resize=remote`.
262
+ Previously a fixed framebuffer was letterboxed inside a widescreen panel.
263
+ - noVNC's own floating toolbar is hidden (`xkat_controls=1` restores it), and the
264
+ proxy signals the player when the canvas is live instead of leaving the panel
265
+ to wait out a fixed timeout.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # xkat Agent
1
+ # xkat-cli
2
2
 
3
- xkat Agent is an AI agent for your machine. It securely connects your browser
3
+ xkat-cli is an AI agent for your machine. It securely connects your browser
4
4
  to your local environment, turning the power of your computer into capabilities
5
5
  the web experience can use.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **Zero-install local shell bridge**: Connects the web IDE terminal in your
10
- browser directly to your local shell. A single `npx xkat up` spawns a native
10
+ browser directly to your local shell. A single `npx xkat-cli up` spawns a native
11
11
  shell (PTY) with your current user permissions — no extra install or setup.
12
12
  - **Extensible agent**: Designed to grow beyond the terminal, bridging more
13
13
  local capabilities over the same secure channel over time.
@@ -21,18 +21,38 @@ the web experience can use.
21
21
  Run instantly via npm without a separate install:
22
22
 
23
23
  ```bash
24
- npx xkat up
24
+ npx xkat-cli up
25
25
  ```
26
26
 
27
27
  `up` starts the agent in the background and returns immediately, so your
28
28
  terminal stays free. Manage it with:
29
29
 
30
30
  ```bash
31
- xkat up # start the agent in the background (alias: start)
32
- xkat down # stop the background agent (alias: stop)
33
- xkat status # show whether the agent is running
31
+ xkat-cli up # start the agent in the background (alias: start)
32
+ xkat-cli down # stop the background agent (alias: stop)
33
+ xkat-cli status # show whether the agent is running
34
+ xkat-cli logs # tail the background agent log
35
+ xkat-cli token # show the pairing token (see Security guidelines)
36
+ xkat-cli version # print the CLI version
34
37
  ```
35
38
 
39
+ `down` stops only processes it can verify are xkat agents. Anything else holding
40
+ a port in 38000..38050 is left running and reported.
41
+
42
+ ### Environment variables
43
+
44
+ | Variable | Effect |
45
+ | :--- | :--- |
46
+ | `XKAT_REQUIRE_TOKEN=1` | Require the pairing token for every session (see Security guidelines). |
47
+ | `XKAT_ALLOWED_ORIGINS` | Comma-separated extra origins allowed to reach the agent. |
48
+ | `XKAT_WATCH_DIR` | Directory mirrored to the browser editor. Defaults to `xkat-practice`. |
49
+ | `XKAT_UBUNTU_ISO_URL` | Mirror for the Ubuntu install image. |
50
+ | `XKAT_OPENSUSE_ISO_URL` | Mirror for the openSUSE install image. |
51
+
52
+ Each VM profile declares its own mirror variable as `vm.media.urlEnv` in
53
+ `profiles.json`; the defaults point at a Korean mirror because that is where the
54
+ audience is.
55
+
36
56
  ### Lab (lecture practice environment)
37
57
 
38
58
  Some courses (Linux, networking, agent tools) run inside an isolated **Lab**
@@ -40,14 +60,16 @@ so the learner’s host OS stays consistent and safer. Learners only use these
40
60
  commands — they do not need to know about hypervisors or VMs:
41
61
 
42
62
  ```bash
43
- xkat lab install # one-time: prepare Lab tools (may ask for admin)
44
- xkat lab up # start Lab
45
- xkat lab shell # open a shell inside Lab (starts Lab if needed)
46
- xkat lab stop # stop Lab (keeps data)
47
- xkat lab delete # remove Lab completely (frees disk; no leftovers)
48
- xkat lab status # is Lab ready?
49
- xkat lab doctor # setup checks + fix hints
50
- xkat lab reset # wipe and recreate Lab
63
+ xkat-cli lab install # one-time: prepare Lab tools (may ask for admin)
64
+ xkat-cli lab up <profile> # start Lab
65
+ xkat-cli lab shell <profile> # open a shell inside Lab (starts Lab if needed)
66
+ xkat-cli lab stop # stop every Lab (keeps data)
67
+ xkat-cli lab delete # remove all Lab containers (saved work is kept)
68
+ xkat-cli lab status <profile> # is Lab ready? (alias: doctor)
69
+ xkat-cli lab reset <profile> # rebuild this Lab (saved work is kept)
70
+ xkat-cli lab purge <profile> # delete this Lab AND its saved work (cannot be undone)
71
+ xkat-cli lab media <profile> # pre-download a VM lab's install image (optional;
72
+ # lectures normally download it themselves)
51
73
  ```
52
74
 
53
75
  `install` is explicit so the learner consents to system setup once.
@@ -61,40 +83,137 @@ Or install globally:
61
83
 
62
84
  ```bash
63
85
  npm install -g xkat-cli
64
- xkat up
86
+ xkat-cli up
87
+ ```
88
+
89
+ ### Lab profiles
90
+
91
+ How a lab is provisioned — base image, CPU platform, devices, and the optional
92
+ QEMU virtual machine with its install media — is declared in
93
+ [`profiles.json`](./profiles.json). Adding an OS means adding an entry there, not
94
+ changing Rust. Lectures select one with the `labProfile` attribute
95
+ (see [the lecture spec](../../lectures/xkat-lecture-spec.md)).
96
+
97
+ A profile with a `vm` block gets the `xkat-lab-vm` control script installed into
98
+ its container:
99
+
100
+ ```bash
101
+ xkat-lab-vm start # boot; says whether it booted the install
102
+ # image or the system you already installed
103
+ xkat-lab-vm reinstall # wipe the install marker and virtual disk so
104
+ # the lecture can install from scratch.
105
+ # Does not boot — the lecture teaches that
106
+ xkat-lab-vm boot-disk # detach the install media and boot from disk
107
+ xkat-lab-vm stop
108
+ xkat-lab-vm status [--porcelain] # running / installed / install image present
65
109
  ```
66
110
 
111
+ The script owns that judgement: the agent reads `status --porcelain` rather than
112
+ guessing from process lists.
113
+
114
+ It uses KVM whenever it can. Under rootless podman the container's `ubuntu` user
115
+ maps to a host subuid that cannot reach `/dev/kvm`, so the qemu process alone is
116
+ elevated to keep hardware acceleration — the difference between a few minutes and
117
+ a quarter of an hour on a desktop install. Only when KVM is genuinely absent does
118
+ it fall back to software emulation, and it says so.
119
+
120
+ qemu and websockify are detached, so they outlive the command that started them
121
+ and a closed terminal never interrupts an install.
122
+
123
+ ### Where lab data lives
124
+
125
+ | Path | Contents |
126
+ | :--- | :--- |
127
+ | `~/.xkat/labs/<profile>/` | Learner data — VM disks, downloaded images, workspace. Mounted at `/home/ubuntu/lab`. Survives `reset` and `delete`. |
128
+ | `~/.xkat/labs/<profile>/<workdir>/` | Per-lecture project folder, created by the platform from the profile's `workdir`. The learner's terminal starts one level above it. |
129
+ | `~/.xkat/iso/` | Shared install-media cache, mounted read-only at `/opt/xkat/iso`. |
130
+ | `~/.xkat/agent.json` | Runtime state (pid, port). Cleared by `down`. |
131
+ | `~/.xkat/token` | Pairing token, owner-readable only. |
132
+ | `~/.xkat/agent.log` | Agent log, rotated at 5MB. |
133
+
134
+ Under rootless podman the lab data belongs to a subuid, so `rm -rf` on it fails.
135
+ Use `xkat-cli lab purge <profile>` to remove a lab together with its data.
136
+
67
137
  ## System requirements
68
138
 
69
139
  - **Node.js**: 16.x or newer.
70
140
 
71
141
  ## Security guidelines
72
142
 
73
- xkat Agent includes the following safeguards:
143
+ xkat-cli includes the following safeguards:
74
144
 
75
- 1. **Localhost only**: Port 10022 is reachable only from localhost.
145
+ 1. **Localhost only**: The agent listens on 38000~38050, and a lab publishes its
146
+ desktop and SSH ports (6080~6099 / 5022~5041, chosen when the container is
147
+ created) — all bound to 127.0.0.1 and reachable only from this machine.
76
148
  2. **Origin verification**: Only connection requests from the official service
77
- domains are accepted.
78
- 3. **Audit log**: Every connection attempt and file-change event is recorded
79
- locally with a timestamp.
80
- 4. **Binary Integrity Check**: During installation, the postinstall script downloads
149
+ domains are accepted (extend with `XKAT_ALLOWED_ORIGINS`).
150
+ 3. **Explicit session modes**: a session must ask for `lab:<profile>`, `host`, or
151
+ `ssh:<user>:<password>`. An unrecognised request is refused rather than
152
+ falling back to a shell on your machine.
153
+ 4. **Optional pairing token** (`XKAT_REQUIRE_TOKEN=1`): the agent then requires a
154
+ token before granting any session. Read it with `xkat-cli token`; it is stored
155
+ at `~/.xkat/token` with owner-only permissions.
156
+
157
+ ```bash
158
+ XKAT_REQUIRE_TOKEN=1 xkat-cli # the browser asks for the token once
159
+ ```
160
+
161
+ **What this does and does not protect.** On a single-user machine a local
162
+ process already runs with your privileges and can read the token file, so the
163
+ token adds little there — it is off by default for that reason. On a shared or
164
+ managed machine it stops *another account's* process from taking a session.
165
+ Requests from a browser are already constrained by the Origin allowlist.
166
+ 5. **Audit log**: Connection attempts are recorded locally with a timestamp in
167
+ `~/.xkat/agent.log`; every host-shell grant is logged with its origin.
168
+ 6. **Binary Integrity Check**: During installation, the postinstall script downloads
81
169
  the platform-specific prebuilt Rust binary over HTTPS and cryptographically
82
- verifies its hash (SHA-256) against a build-time checksum manifest to prevent
83
- MITM, tampering, or supply chain injection.
170
+ verifies its hash (SHA-256) against the checksum manifest published with the
171
+ package, to prevent MITM, tampering, or supply chain injection. Installation
172
+ **fails** if the manifest is missing an entry or the hash does not match — an
173
+ unverified binary is never installed.
174
+
175
+ ## Consumers
176
+
177
+ Two things drive this CLI, and both break if a command is renamed or its meaning
178
+ changes:
179
+
180
+ - **The web player** talks to the running agent over HTTP/WebSocket
181
+ (`/lab/exec`, `/lab/status`, `/ws`) to provision labs and attach terminals.
182
+ - **The desktop app** (`apps/desktop`) resolves this binary on disk and runs it
183
+ for its `Tools` menu — see
184
+ [docs/architecture/desktop_app.md](../../docs/architecture/desktop_app.md).
185
+ It accepts only a binary named `xkat-cli`.
186
+
187
+ ## Changelog
188
+
189
+ See [CHANGELOG.md](./CHANGELOG.md). The 2.1.x–2.7.x line carries behavior
190
+ changes worth reading before upgrading — dynamic lab ports, per-lecture project
191
+ folders, virtual-machine state reported to the player, and `down` now stopping
192
+ lab virtual machines.
84
193
 
85
194
  ## Releasing (maintainers)
86
195
 
87
- See `docs/xkat-agent/deployment-pipeline.md` and
88
- `.github/workflows/release-agent.yml` for the release pipeline. Summary:
196
+ See `.github/workflows/release-agent.yml` for the release pipeline. Binaries go
197
+ to R2; npm carries only this wrapper.
198
+
199
+ npm publishing uses **trusted publishing (OIDC)** — there is no long-lived token
200
+ in repository secrets. The package's Trusted Publisher must list this repository
201
+ and `release-agent.yml`; without it `npm publish` is refused.
202
+
203
+ `package.json` and `Cargo.toml` versions must match the tag — CI rejects the
204
+ release otherwise, because the npm version selects the CDN folder while the
205
+ binary reports its own crate version.
89
206
 
90
207
  ```bash
91
208
  cd apps/xkat-agent
92
- npm version patch --no-git-tag-version
93
- git add package.json
94
- git commit -m "chore(agent): release v$(node -p "require('./package.json').version")"
209
+ VER=2.1.12 # bump both files to the same value
210
+ npm version "$VER" --no-git-tag-version
211
+ sed -i "0,/^version = /s//version = \"$VER\"\n/" Cargo.toml # or edit by hand
212
+ cargo check # refresh Cargo.lock
213
+ git add package.json Cargo.toml Cargo.lock
214
+ git commit -m "chore(agent): release v$VER"
95
215
  git push origin main
96
- git tag agent-v$(node -p "require('./package.json').version")
97
- git push origin agent-v$(node -p "require('./package.json').version")
216
+ git tag "agent-v$VER" && git push origin "agent-v$VER"
98
217
  ```
99
218
 
100
219
  ## License
@@ -1,7 +1,7 @@
1
1
  {
2
- "xkat-agent-macos-aarch64": "2302708ec3671a348d7097ca37c8fdfb8f7e7075677b77bdcd3a81f925c7a08d",
3
- "xkat-agent-macos-x64": "0327efb22cb81327f139fe93745453c561ccfd8e63255e3277c9feb5de497d0a",
4
- "xkat-agent-linux-x64": "2ca8bfd35f05398b8e90e4dfa1b5d702f7a81fc5881e4be9b3e8df11345860e9",
5
- "xkat-agent-linux-aarch64": "6d2db70dd5e7a02c275106f3d3d0abf6ebfdc19d526f6deb952753df3f1f7584",
6
- "xkat-agent-win-x64.exe": "7116843eaf46be1c75f3170f91b15d745e4a8a0bf6cdf54ec436095fd27082f1"
2
+ "xkat-cli-macos-aarch64": "c43d8536d1b517eda8c9742cb8a95bf902eb35e0f00a65258c16579ca2c9611d",
3
+ "xkat-cli-macos-x64": "5b3a93211d095b8eb2f1ed574b3b06266aeb5e15ec127769a01650fca3f9c20b",
4
+ "xkat-cli-linux-x64": "4cd7d742a0dbc854119f1cdf45ebd831ad617b6d746a130bd18c11f75baae18b",
5
+ "xkat-cli-linux-aarch64": "1f8054b8647d010f49f00479da6f6c04b1296b3665d23d63d45914f376fadfa2",
6
+ "xkat-cli-win-x64.exe": "c52ba038212395ef86dc96882d9b884dcb15f387ac57c7757aae7ef428776ab7"
7
7
  }
package/bin/xkat-cli.js CHANGED
@@ -21,11 +21,11 @@ function getBinaryName() {
21
21
  const arch = os.arch();
22
22
 
23
23
  const map = {
24
- 'darwin-arm64': 'xkat-agent-macos-aarch64',
25
- 'darwin-x64': 'xkat-agent-macos-x64',
26
- 'linux-x64': 'xkat-agent-linux-x64',
27
- 'linux-arm64': 'xkat-agent-linux-aarch64',
28
- 'win32-x64': 'xkat-agent-win-x64.exe',
24
+ 'darwin-arm64': 'xkat-cli-macos-aarch64',
25
+ 'darwin-x64': 'xkat-cli-macos-x64',
26
+ 'linux-x64': 'xkat-cli-linux-x64',
27
+ 'linux-arm64': 'xkat-cli-linux-aarch64',
28
+ 'win32-x64': 'xkat-cli-win-x64.exe',
29
29
  };
30
30
 
31
31
  const key = `${platform}-${arch}`;
@@ -40,97 +40,42 @@ function getBinaryName() {
40
40
  return binary;
41
41
  }
42
42
 
43
- const LAB_NAME = 'xkat-lab';
44
-
45
43
  const candidatePaths = [
46
- path.join(__dirname, '..', 'target', 'release', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
47
- path.join(__dirname, '..', 'target', 'debug', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
44
+ path.join(__dirname, '..', 'target', 'release', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
45
+ path.join(__dirname, '..', 'target', 'debug', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
48
46
  path.join(__dirname, '..', 'binaries', getBinaryName()),
49
- path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-agent.exe' : 'xkat-agent'),
47
+ path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
50
48
  ];
51
49
 
52
- const binaryPath = candidatePaths.find((p) => fs.existsSync(p));
50
+ const binaryPath = candidatePaths.find((p) => {
51
+ if (!fs.existsSync(p)) return false;
52
+ const stats = fs.statSync(p);
53
+ return stats.size > 0;
54
+ });
53
55
 
54
56
  const args = process.argv.slice(2);
55
- const subCmd = args.join(' ').trim();
56
-
57
- // ── 1. Zero-Touch Port 38000..38050 & 10022 Cleanup Command ──
58
- if (subCmd === 'down' || subCmd === 'stop' || subCmd === 'kill' || subCmd === 'clean') {
59
- console.log('🛑 Cleaning up agent ports (38000..38050, 10022) and stopping existing xkat agent instances...');
60
- if (os.platform() === 'win32') {
61
- spawnSync('powershell', [
62
- '-NoProfile',
63
- '-NonInteractive',
64
- '-Command',
65
- '38000..38050 + @(10022) | ForEach-Object { Get-NetTCPConnection -LocalPort $_ -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | Where-Object { $_ -gt 0 } | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } }; Get-Process -Name xkat-agent*, xkat* -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue'
66
- ], { stdio: 'inherit' });
67
- } else {
68
- spawnSync('sh', ['-c', 'fuser -k 38000-38050/tcp 2>/dev/null || true; fuser -k 10022/tcp 2>/dev/null || true; pgrep -f "xkat-agent" | xargs -r kill -9 2>/dev/null || true'], { stdio: 'inherit' });
69
- }
70
- console.log('✅ Agent ports are now completely free.');
71
- process.exit(0);
72
- }
73
57
 
74
- function getMultipassCmd() {
75
- if (os.platform() === 'win32') {
76
- const candidates = [
77
- 'multipass',
78
- path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Multipass', 'bin', 'multipass.exe'),
79
- path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Multipass', 'bin', 'multipass.exe'),
80
- path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Multipass', 'bin', 'multipass.exe'),
81
- ];
82
- for (const c of candidates) {
83
- if (c === 'multipass' || fs.existsSync(c)) return c;
84
- }
85
- }
86
- return 'multipass';
87
- }
88
-
89
- // ── 2. Fallback for Lab VM commands when native agent is not built ──
58
+ // Every subcommand — `down` included — is handled by the Rust binary. This
59
+ // wrapper used to reimplement shutdown by killing everything on ports
60
+ // 38000..38050 plus every process matching "xkat-cli", which included itself
61
+ // and any unrelated process with that string in its command line.
90
62
  if (!binaryPath) {
91
- const mp = getMultipassCmd();
92
- if (subCmd === 'lab shell') {
93
- const result = spawnSync(mp, ['shell', LAB_NAME], { stdio: 'inherit', shell: os.platform() === 'win32' });
94
- process.exit(result.status || 0);
95
- }
96
-
97
- if (subCmd === 'lab reset') {
98
- console.log('🔄 Resetting Lab VM...');
99
- spawnSync(mp, ['delete', LAB_NAME, '--purge'], { stdio: 'inherit', shell: os.platform() === 'win32' });
100
- console.log('🚀 Launching fresh Lab VM...');
101
- const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
102
- if (launch.status === 0) {
103
- console.log('✅ Lab reset complete.');
104
- console.log(' Enter: npx -y xkat-cli@latest lab shell');
105
- } else {
106
- console.error('❌ Could not recreate Lab. Check multipass installation.');
107
- }
108
- process.exit(launch.status || 0);
109
- }
110
-
111
- if (subCmd === 'lab up' || subCmd === 'lab install') {
112
- const info = spawnSync(mp, ['info', LAB_NAME], { encoding: 'utf8', shell: os.platform() === 'win32' });
113
- if (info.status !== 0) {
114
- console.log('🚀 Creating Lab VM...');
115
- const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
116
- process.exit(launch.status || 0);
117
- } else {
118
- console.log(`✅ Lab VM '${LAB_NAME}' already exists.`);
119
- console.log(' Enter: npx -y xkat-cli@latest lab shell');
120
- process.exit(0);
121
- }
122
- }
123
-
124
- console.error('❌ xkat-agent binary not found.');
125
- console.error(' Try reinstalling: npm install -g xkat-cli');
126
- console.error(' Or for local development: cargo build');
63
+ console.error('❌ xkat-cli binary not found.');
64
+ console.error(' Reinstall to fetch it: npm install -g xkat-cli');
65
+ console.error(' Or build from source: cd apps/xkat-agent && cargo build --release');
127
66
  process.exit(1);
128
67
  }
129
68
 
130
- // ── 3. Forward to Rust binary ──
69
+ // Forward to the Rust binary.
131
70
  const result = spawnSync(binaryPath, args, {
132
71
  stdio: 'inherit',
133
72
  env: process.env,
134
73
  });
135
74
 
136
- process.exit(result.status || 0);
75
+ if (result.error) {
76
+ console.error(`❌ Could not run xkat-cli: ${result.error.message}`);
77
+ process.exit(1);
78
+ }
79
+
80
+ // A signalled child reports status === null; treat that as a failure, not success.
81
+ process.exit(result.status ?? (result.signal ? 1 : 0));
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "xkat-cli",
3
- "version": "2.0.17",
4
- "description": "xkat Agent - an AI agent that securely connects your browser to your machine",
3
+ "version": "2.7.1",
4
+ "description": "xkat-cli - an AI agent that securely connects your browser to your machine",
5
5
  "main": "bin/xkat-cli.js",
6
6
  "bin": {
7
- "xkat": "bin/xkat-cli.js"
7
+ "xkat-cli": "bin/xkat-cli.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
11
  "scripts",
12
- "README.md"
12
+ "README.md",
13
+ "CHANGELOG.md"
13
14
  ],
14
15
  "scripts": {
15
- "build:win": "cargo build --target x86_64-pc-windows-gnu --release && cp target/x86_64-pc-windows-gnu/release/xkat-agent.exe binaries/xkat-agent-win-x64.exe",
16
- "build:linux": "cargo build --release && cp target/release/xkat-agent binaries/xkat-agent-linux-x64",
16
+ "build:win": "cargo build --target x86_64-pc-windows-gnu --release && cp target/x86_64-pc-windows-gnu/release/xkat-cli.exe binaries/xkat-cli-win-x64.exe",
17
+ "build:linux": "cargo build --release && cp target/release/xkat-cli binaries/xkat-cli-linux-x64",
17
18
  "postinstall": "node ./scripts/install.js"
18
19
  },
19
20
  "keywords": [
@@ -21,12 +21,14 @@ for (const dir of candidateDirs) {
21
21
 
22
22
  const destFile = path.resolve(__dirname, '..', 'bin', 'checksums.json');
23
23
 
24
+ // Must stay identical to the keys install.js looks up (getPlatformTarget),
25
+ // otherwise integrity verification silently degrades to "no check".
24
26
  const targets = [
25
- 'xkat-agent-macos-aarch64',
26
- 'xkat-agent-macos-x64',
27
- 'xkat-agent-linux-x64',
28
- 'xkat-agent-linux-aarch64',
29
- 'xkat-agent-win-x64.exe',
27
+ 'xkat-cli-macos-aarch64',
28
+ 'xkat-cli-macos-x64',
29
+ 'xkat-cli-linux-x64',
30
+ 'xkat-cli-linux-aarch64',
31
+ 'xkat-cli-win-x64.exe',
30
32
  ];
31
33
 
32
34
  const checksums = {};
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * install.js — postinstall script
3
3
  * Runs automatically right after `npm install`. Downloads the prebuilt Rust
4
- * binary matching the user's OS/architecture from GitHub Releases (or R2).
4
+ * binary matching the user's OS/architecture from the release CDN and verifies
5
+ * it against the checksum manifest published with this package.
5
6
  */
6
7
  process.removeAllListeners('warning');
7
8
  process.emitWarning = () => {};
@@ -14,20 +15,21 @@ const https = require('https');
14
15
  const crypto = require('crypto');
15
16
 
16
17
  // Version is the single source of truth from package.json, so a release bump
17
- // (npm version patch) automatically points install at the matching R2 folder.
18
+ // automatically points install at the matching CDN folder.
18
19
  const VERSION = require('../package.json').version;
19
20
  const BASE_URL = `https://dl.xkat.space/v${VERSION}`;
21
+ const MAX_REDIRECTS = 5;
20
22
 
21
23
  function getPlatformTarget() {
22
24
  const platform = os.platform();
23
25
  const arch = os.arch();
24
26
 
25
27
  const map = {
26
- 'darwin-arm64': 'xkat-agent-macos-aarch64',
27
- 'darwin-x64': 'xkat-agent-macos-x64',
28
- 'linux-x64': 'xkat-agent-linux-x64',
29
- 'linux-arm64': 'xkat-agent-linux-aarch64',
30
- 'win32-x64': 'xkat-agent-win-x64.exe',
28
+ 'darwin-arm64': 'xkat-cli-macos-aarch64',
29
+ 'darwin-x64': 'xkat-cli-macos-x64',
30
+ 'linux-x64': 'xkat-cli-linux-x64',
31
+ 'linux-arm64': 'xkat-cli-linux-aarch64',
32
+ 'win32-x64': 'xkat-cli-win-x64.exe',
31
33
  };
32
34
 
33
35
  return map[`${platform}-${arch}`] || null;
@@ -39,106 +41,153 @@ function calculateSha256(filePath) {
39
41
  const stream = fs.createReadStream(filePath);
40
42
  stream.on('data', (data) => hash.update(data));
41
43
  stream.on('end', () => resolve(hash.digest('hex')));
42
- stream.on('error', (err) => reject(err));
44
+ stream.on('error', reject);
43
45
  });
44
46
  }
45
47
 
48
+ /**
49
+ * True when running from the agent's own source tree rather than an installed
50
+ * package. `Cargo.toml` is not in package.json "files", so it can only exist
51
+ * here during development — unlike a pnpm-workspace probe, which also matches
52
+ * a user installing xkat-cli into their own monorepo.
53
+ */
54
+ function isSourceCheckout() {
55
+ return fs.existsSync(path.join(__dirname, '..', 'Cargo.toml'));
56
+ }
57
+
58
+ function fail(message, hint) {
59
+ console.error(`❌ xkat-cli: ${message}`);
60
+ if (hint) console.error(` ${hint}`);
61
+ process.exitCode = 1;
62
+ }
63
+
46
64
  async function main() {
47
- const target = getPlatformTarget();
65
+ if (isSourceCheckout()) {
66
+ console.log('🛠️ Agent source tree detected. Skipping binary download.');
67
+ console.log(" (Use 'cargo build --release' for development)");
68
+ return;
69
+ }
48
70
 
71
+ const target = getPlatformTarget();
49
72
  if (!target) {
50
- console.log(`⚠️ xkat-agent: unsupported platform (${os.platform()}-${os.arch()})`);
73
+ // Not a failure: the package still installs, the user builds from source.
74
+ console.log(`⚠️ xkat-cli: unsupported platform (${os.platform()}-${os.arch()})`);
51
75
  console.log(' Build manually: cd apps/xkat-agent && cargo build --release');
52
76
  return;
53
77
  }
54
78
 
55
- const binDir = path.join(__dirname, '..', 'binaries');
56
- const destPath = path.join(binDir, target);
57
-
58
- // Load checksums manifest if it exists
79
+ // Integrity verification is a documented guarantee, so a missing or
80
+ // incomplete manifest must fail loudly instead of silently skipping the check.
59
81
  const checksumsPath = path.join(__dirname, '..', 'bin', 'checksums.json');
60
82
  let expectedHash = null;
61
- if (fs.existsSync(checksumsPath)) {
62
- try {
63
- const checksums = JSON.parse(fs.readFileSync(checksumsPath, 'utf8'));
64
- expectedHash = checksums[target] || null;
65
- } catch (e) {
66
- console.warn(`⚠️ Failed to parse checksums manifest: ${e.message}`);
67
- }
83
+ try {
84
+ expectedHash = JSON.parse(fs.readFileSync(checksumsPath, 'utf8'))[target] || null;
85
+ } catch (e) {
86
+ return fail(
87
+ `checksum manifest is unreadable (${e.message}).`,
88
+ 'This package looks corrupted — reinstall with: npm install -g xkat-cli'
89
+ );
90
+ }
91
+ if (!expectedHash) {
92
+ return fail(
93
+ `checksum manifest has no entry for ${target}.`,
94
+ 'Refusing to install an unverifiable binary. Please report this release.'
95
+ );
68
96
  }
69
97
 
70
- // Skip if already downloaded and verified
98
+ const binDir = path.join(__dirname, '..', 'binaries');
99
+ const destPath = path.join(binDir, target);
100
+
71
101
  if (fs.existsSync(destPath)) {
72
- if (expectedHash) {
73
- try {
74
- const currentHash = await calculateSha256(destPath);
75
- if (currentHash === expectedHash) {
76
- console.log(`✅ xkat-agent binary already present and verified: ${target}`);
77
- return;
78
- }
79
- console.log(`⚠️ Hash mismatch for existing binary. Re-downloading...`);
80
- } catch (err) {
81
- console.log(`⚠️ Failed to verify existing binary hash: ${err.message}. Re-downloading...`);
102
+ try {
103
+ if ((await calculateSha256(destPath)) === expectedHash) {
104
+ console.log(`✅ xkat-cli binary already present and verified: ${target}`);
105
+ return;
82
106
  }
83
- } else {
84
- console.log(`✅ xkat-agent binary already present: ${target}`);
85
- return;
107
+ console.log('⚠️ Hash mismatch for existing binary. Re-downloading...');
108
+ } catch (err) {
109
+ console.log(`⚠️ Could not verify existing binary (${err.message}). Re-downloading...`);
86
110
  }
87
111
  }
88
112
 
89
- console.log(`📥 Downloading xkat-agent... (${target})`);
90
-
91
- // Create the binaries directory
92
- if (!fs.existsSync(binDir)) {
93
- fs.mkdirSync(binDir, { recursive: true });
94
- }
95
-
96
- const url = `${BASE_URL}/${target}`;
113
+ console.log(`📥 Downloading xkat-cli... (${target})`);
114
+ fs.mkdirSync(binDir, { recursive: true });
97
115
 
116
+ // Download to a temporary file and only publish it under the final name once
117
+ // it is complete and verified, so an interrupted download can never be cached
118
+ // as a usable binary.
119
+ const partPath = `${destPath}.part`;
98
120
  try {
99
- await downloadFile(url, destPath);
100
-
101
- // Verify integrity after download
102
- if (expectedHash) {
103
- const downloadedHash = await calculateSha256(destPath);
104
- if (downloadedHash !== expectedHash) {
105
- // Remove corrupted/tampered file
106
- try { fs.unlinkSync(destPath); } catch (_) {}
107
- throw new Error(`Integrity check failed: expected ${expectedHash}, got ${downloadedHash}`);
108
- }
109
- console.log(`✅ Integrity verified: SHA-256 match`);
121
+ fs.rmSync(partPath, { force: true });
122
+ await downloadFile(`${BASE_URL}/${target}`, partPath);
123
+
124
+ const downloadedHash = await calculateSha256(partPath);
125
+ if (downloadedHash !== expectedHash) {
126
+ throw new Error(`integrity check failed: expected ${expectedHash}, got ${downloadedHash}`);
110
127
  }
111
128
 
112
- // Grant execute permission (Unix only)
113
129
  if (os.platform() !== 'win32') {
114
- fs.chmodSync(destPath, 0o755);
130
+ fs.chmodSync(partPath, 0o755);
115
131
  }
116
- console.log(`✅ Installed! Run: xkat up`);
132
+ fs.renameSync(partPath, destPath);
133
+
134
+ console.log('✅ Integrity verified: SHA-256 match');
135
+ console.log('✅ Installed! Run: xkat-cli up');
117
136
  } catch (err) {
118
- console.log(`⚠️ Binary download failed: ${err.message}`);
119
- console.log(' To build locally: cd apps/xkat-agent && cargo build --release');
137
+ fs.rmSync(partPath, { force: true });
138
+ fail(
139
+ `binary download failed: ${err.message}`,
140
+ 'Retry the install, or build locally: cd apps/xkat-agent && cargo build --release'
141
+ );
120
142
  }
121
143
  }
122
144
 
123
- function downloadFile(urlStr, dest) {
145
+ function downloadFile(urlStr, dest, redirectsLeft = MAX_REDIRECTS) {
124
146
  return new Promise((resolve, reject) => {
125
- const file = fs.createWriteStream(dest);
126
- const parsedUrl = new URL(urlStr);
127
- https.get(parsedUrl, (response) => {
128
- // GitHub Releases use 302 redirects
129
- if (response.statusCode === 302 || response.statusCode === 301) {
130
- const redirectUrl = new URL(response.headers.location, parsedUrl);
131
- https.get(redirectUrl, (redirected) => {
132
- redirected.pipe(file);
133
- file.on('finish', () => { file.close(); resolve(); });
134
- }).on('error', reject);
135
- } else if (response.statusCode === 200) {
136
- response.pipe(file);
137
- file.on('finish', () => { file.close(); resolve(); });
138
- } else {
139
- reject(new Error(`HTTP ${response.statusCode}`));
147
+ const request = https.get(new URL(urlStr), (response) => {
148
+ const { statusCode, headers } = response;
149
+
150
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
151
+ response.resume(); // drain so the socket can be reused
152
+ if (redirectsLeft <= 0) {
153
+ reject(new Error('too many redirects'));
154
+ return;
155
+ }
156
+ const next = new URL(headers.location, urlStr).toString();
157
+ downloadFile(next, dest, redirectsLeft - 1).then(resolve, reject);
158
+ return;
159
+ }
160
+
161
+ if (statusCode !== 200) {
162
+ response.resume();
163
+ reject(new Error(`HTTP ${statusCode} for ${urlStr}`));
164
+ return;
140
165
  }
141
- }).on('error', reject);
166
+
167
+ const expectedBytes = Number(headers['content-length']) || 0;
168
+ let received = 0;
169
+ response.on('data', (chunk) => { received += chunk.length; });
170
+
171
+ const file = fs.createWriteStream(dest);
172
+ response.pipe(file);
173
+
174
+ response.on('error', reject);
175
+ file.on('error', reject);
176
+ file.on('finish', () => {
177
+ file.close(() => {
178
+ if (expectedBytes && received !== expectedBytes) {
179
+ reject(new Error(`truncated download: got ${received} of ${expectedBytes} bytes`));
180
+ return;
181
+ }
182
+ resolve();
183
+ });
184
+ });
185
+ });
186
+
187
+ request.on('error', reject);
188
+ request.setTimeout(120000, () => {
189
+ request.destroy(new Error('download timed out'));
190
+ });
142
191
  });
143
192
  }
144
193