xkat-cli 2.0.16 → 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 +265 -0
- package/README.md +150 -31
- package/bin/checksums.json +5 -5
- package/bin/xkat-cli.js +31 -83
- package/package.json +7 -6
- package/scripts/generate-checksums.js +7 -5
- package/scripts/install.js +129 -74
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
|
|
1
|
+
# xkat-cli
|
|
2
2
|
|
|
3
|
-
xkat
|
|
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
|
|
44
|
-
xkat lab up
|
|
45
|
-
xkat lab shell
|
|
46
|
-
xkat lab stop
|
|
47
|
-
xkat lab delete
|
|
48
|
-
xkat lab status
|
|
49
|
-
xkat lab
|
|
50
|
-
xkat 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
|
|
143
|
+
xkat-cli includes the following safeguards:
|
|
74
144
|
|
|
75
|
-
1. **Localhost only**:
|
|
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. **
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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$
|
|
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
|
package/bin/checksums.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"xkat-
|
|
3
|
-
"xkat-
|
|
4
|
-
"xkat-
|
|
5
|
-
"xkat-
|
|
6
|
-
"xkat-
|
|
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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// Suppress Node.js runtime deprecation warnings (e.g. DEP0169)
|
|
4
|
+
process.removeAllListeners('warning');
|
|
5
|
+
process.emitWarning = () => {};
|
|
3
6
|
process.env.NODE_NO_WARNINGS = '1';
|
|
4
7
|
|
|
5
8
|
/**
|
|
@@ -18,11 +21,11 @@ function getBinaryName() {
|
|
|
18
21
|
const arch = os.arch();
|
|
19
22
|
|
|
20
23
|
const map = {
|
|
21
|
-
'darwin-arm64': 'xkat-
|
|
22
|
-
'darwin-x64': 'xkat-
|
|
23
|
-
'linux-x64': 'xkat-
|
|
24
|
-
'linux-arm64': 'xkat-
|
|
25
|
-
'win32-x64': 'xkat-
|
|
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',
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
const key = `${platform}-${arch}`;
|
|
@@ -37,97 +40,42 @@ function getBinaryName() {
|
|
|
37
40
|
return binary;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
const LAB_NAME = 'xkat-lab';
|
|
41
|
-
|
|
42
43
|
const candidatePaths = [
|
|
43
|
-
path.join(__dirname, '..', 'target', 'release', os.platform() === 'win32' ? 'xkat-
|
|
44
|
-
path.join(__dirname, '..', 'target', 'debug', os.platform() === 'win32' ? 'xkat-
|
|
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'),
|
|
45
46
|
path.join(__dirname, '..', 'binaries', getBinaryName()),
|
|
46
|
-
path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-
|
|
47
|
+
path.join(os.homedir(), '.xkat', os.platform() === 'win32' ? 'xkat-cli.exe' : 'xkat-cli'),
|
|
47
48
|
];
|
|
48
49
|
|
|
49
|
-
const binaryPath = candidatePaths.find((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
|
+
});
|
|
50
55
|
|
|
51
56
|
const args = process.argv.slice(2);
|
|
52
|
-
const subCmd = args.join(' ').trim();
|
|
53
|
-
|
|
54
|
-
// ── 1. Zero-Touch Port 38000..38050 & 10022 Cleanup Command ──
|
|
55
|
-
if (subCmd === 'down' || subCmd === 'stop' || subCmd === 'kill' || subCmd === 'clean') {
|
|
56
|
-
console.log('🛑 Cleaning up agent ports (38000..38050, 10022) and stopping existing xkat agent instances...');
|
|
57
|
-
if (os.platform() === 'win32') {
|
|
58
|
-
spawnSync('powershell', [
|
|
59
|
-
'-NoProfile',
|
|
60
|
-
'-NonInteractive',
|
|
61
|
-
'-Command',
|
|
62
|
-
'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'
|
|
63
|
-
], { stdio: 'inherit' });
|
|
64
|
-
} else {
|
|
65
|
-
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' });
|
|
66
|
-
}
|
|
67
|
-
console.log('✅ Agent ports are now completely free.');
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
70
57
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Multipass', 'bin', 'multipass.exe'),
|
|
76
|
-
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Multipass', 'bin', 'multipass.exe'),
|
|
77
|
-
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Multipass', 'bin', 'multipass.exe'),
|
|
78
|
-
];
|
|
79
|
-
for (const c of candidates) {
|
|
80
|
-
if (c === 'multipass' || fs.existsSync(c)) return c;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return 'multipass';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── 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.
|
|
87
62
|
if (!binaryPath) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
process.exit(result.status || 0);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (subCmd === 'lab reset') {
|
|
95
|
-
console.log('🔄 Resetting Lab VM...');
|
|
96
|
-
spawnSync(mp, ['delete', LAB_NAME, '--purge'], { stdio: 'inherit', shell: os.platform() === 'win32' });
|
|
97
|
-
console.log('🚀 Launching fresh Lab VM...');
|
|
98
|
-
const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
|
|
99
|
-
if (launch.status === 0) {
|
|
100
|
-
console.log('✅ Lab reset complete.');
|
|
101
|
-
console.log(' Enter: npx -y xkat-cli@latest lab shell');
|
|
102
|
-
} else {
|
|
103
|
-
console.error('❌ Could not recreate Lab. Check multipass installation.');
|
|
104
|
-
}
|
|
105
|
-
process.exit(launch.status || 0);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (subCmd === 'lab up' || subCmd === 'lab install') {
|
|
109
|
-
const info = spawnSync(mp, ['info', LAB_NAME], { encoding: 'utf8', shell: os.platform() === 'win32' });
|
|
110
|
-
if (info.status !== 0) {
|
|
111
|
-
console.log('🚀 Creating Lab VM...');
|
|
112
|
-
const launch = spawnSync(mp, ['launch', '--name', LAB_NAME, '24.04', '--cpus', '2', '--memory', '2G', '--disk', '10G'], { stdio: 'inherit', shell: os.platform() === 'win32' });
|
|
113
|
-
process.exit(launch.status || 0);
|
|
114
|
-
} else {
|
|
115
|
-
console.log(`✅ Lab VM '${LAB_NAME}' already exists.`);
|
|
116
|
-
console.log(' Enter: npx -y xkat-cli@latest lab shell');
|
|
117
|
-
process.exit(0);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
console.error('❌ xkat-agent binary not found.');
|
|
122
|
-
console.error(' Try reinstalling: npm install -g xkat-cli');
|
|
123
|
-
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');
|
|
124
66
|
process.exit(1);
|
|
125
67
|
}
|
|
126
68
|
|
|
127
|
-
//
|
|
69
|
+
// Forward to the Rust binary.
|
|
128
70
|
const result = spawnSync(binaryPath, args, {
|
|
129
71
|
stdio: 'inherit',
|
|
130
72
|
env: process.env,
|
|
131
73
|
});
|
|
132
74
|
|
|
133
|
-
|
|
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.
|
|
4
|
-
"description": "xkat
|
|
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-
|
|
16
|
-
"build:linux": "cargo build --release && cp target/release/xkat-
|
|
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-
|
|
26
|
-
'xkat-
|
|
27
|
-
'xkat-
|
|
28
|
-
'xkat-
|
|
29
|
-
'xkat-
|
|
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 = {};
|
package/scripts/install.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
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
|
|
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
|
*/
|
|
7
|
+
process.removeAllListeners('warning');
|
|
8
|
+
process.emitWarning = () => {};
|
|
9
|
+
process.env.NODE_NO_WARNINGS = '1';
|
|
10
|
+
|
|
6
11
|
const os = require('os');
|
|
7
12
|
const fs = require('fs');
|
|
8
13
|
const path = require('path');
|
|
@@ -10,20 +15,21 @@ const https = require('https');
|
|
|
10
15
|
const crypto = require('crypto');
|
|
11
16
|
|
|
12
17
|
// Version is the single source of truth from package.json, so a release bump
|
|
13
|
-
//
|
|
18
|
+
// automatically points install at the matching CDN folder.
|
|
14
19
|
const VERSION = require('../package.json').version;
|
|
15
20
|
const BASE_URL = `https://dl.xkat.space/v${VERSION}`;
|
|
21
|
+
const MAX_REDIRECTS = 5;
|
|
16
22
|
|
|
17
23
|
function getPlatformTarget() {
|
|
18
24
|
const platform = os.platform();
|
|
19
25
|
const arch = os.arch();
|
|
20
26
|
|
|
21
27
|
const map = {
|
|
22
|
-
'darwin-arm64': 'xkat-
|
|
23
|
-
'darwin-x64': 'xkat-
|
|
24
|
-
'linux-x64': 'xkat-
|
|
25
|
-
'linux-arm64': 'xkat-
|
|
26
|
-
'win32-x64': 'xkat-
|
|
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',
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
return map[`${platform}-${arch}`] || null;
|
|
@@ -35,104 +41,153 @@ function calculateSha256(filePath) {
|
|
|
35
41
|
const stream = fs.createReadStream(filePath);
|
|
36
42
|
stream.on('data', (data) => hash.update(data));
|
|
37
43
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
38
|
-
stream.on('error',
|
|
44
|
+
stream.on('error', reject);
|
|
39
45
|
});
|
|
40
46
|
}
|
|
41
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
|
+
|
|
42
64
|
async function main() {
|
|
43
|
-
|
|
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
|
+
}
|
|
44
70
|
|
|
71
|
+
const target = getPlatformTarget();
|
|
45
72
|
if (!target) {
|
|
46
|
-
|
|
73
|
+
// Not a failure: the package still installs, the user builds from source.
|
|
74
|
+
console.log(`⚠️ xkat-cli: unsupported platform (${os.platform()}-${os.arch()})`);
|
|
47
75
|
console.log(' Build manually: cd apps/xkat-agent && cargo build --release');
|
|
48
76
|
return;
|
|
49
77
|
}
|
|
50
78
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// 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.
|
|
55
81
|
const checksumsPath = path.join(__dirname, '..', 'bin', 'checksums.json');
|
|
56
82
|
let expectedHash = null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
);
|
|
64
96
|
}
|
|
65
97
|
|
|
66
|
-
|
|
98
|
+
const binDir = path.join(__dirname, '..', 'binaries');
|
|
99
|
+
const destPath = path.join(binDir, target);
|
|
100
|
+
|
|
67
101
|
if (fs.existsSync(destPath)) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
console.log(`✅ xkat-agent binary already present and verified: ${target}`);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
console.log(`⚠️ Hash mismatch for existing binary. Re-downloading...`);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
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;
|
|
78
106
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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...`);
|
|
82
110
|
}
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
console.log(`📥 Downloading xkat-
|
|
86
|
-
|
|
87
|
-
// Create the binaries directory
|
|
88
|
-
if (!fs.existsSync(binDir)) {
|
|
89
|
-
fs.mkdirSync(binDir, { recursive: true });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const url = `${BASE_URL}/${target}`;
|
|
113
|
+
console.log(`📥 Downloading xkat-cli... (${target})`);
|
|
114
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
93
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`;
|
|
94
120
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Remove corrupted/tampered file
|
|
102
|
-
try { fs.unlinkSync(destPath); } catch (_) {}
|
|
103
|
-
throw new Error(`Integrity check failed: expected ${expectedHash}, got ${downloadedHash}`);
|
|
104
|
-
}
|
|
105
|
-
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}`);
|
|
106
127
|
}
|
|
107
128
|
|
|
108
|
-
// Grant execute permission (Unix only)
|
|
109
129
|
if (os.platform() !== 'win32') {
|
|
110
|
-
fs.chmodSync(
|
|
130
|
+
fs.chmodSync(partPath, 0o755);
|
|
111
131
|
}
|
|
112
|
-
|
|
132
|
+
fs.renameSync(partPath, destPath);
|
|
133
|
+
|
|
134
|
+
console.log('✅ Integrity verified: SHA-256 match');
|
|
135
|
+
console.log('✅ Installed! Run: xkat-cli up');
|
|
113
136
|
} catch (err) {
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
);
|
|
116
142
|
}
|
|
117
143
|
}
|
|
118
144
|
|
|
119
|
-
function downloadFile(
|
|
145
|
+
function downloadFile(urlStr, dest, redirectsLeft = MAX_REDIRECTS) {
|
|
120
146
|
return new Promise((resolve, reject) => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
134
165
|
}
|
|
135
|
-
|
|
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
|
+
});
|
|
136
191
|
});
|
|
137
192
|
}
|
|
138
193
|
|