From e5477a2a4b3131be39aa80a5e4629d228fa50f36 Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Sat, 9 Aug 2025 18:18:53 +0100 Subject: [PATCH 01/70] Operator API for computer use [init] --- images/chromium-headful/Dockerfile | 311 +++++++++++------- .../client/src/components/video.vue | 57 +++- images/chromium-headful/daemon.conf | 43 +++ images/chromium-headful/dbus-mpris.conf | 27 ++ images/chromium-headful/dbus-pulseaudio.conf | 27 ++ images/chromium-headful/default.pa | 17 + images/chromium-headful/run-unikernel.sh | 5 +- images/chromium-headful/wrapper.sh | 273 +++++++++------ images/chromium-headful/xorg.conf | 4 +- operator-api/.env.example | 11 + operator-api/.gitignore | 7 + operator-api/README.md | 1 + operator-api/README.tests.md | 10 + operator-api/index.js | 27 ++ operator-api/package.json | 36 ++ operator-api/src/app.js | 38 +++ operator-api/src/routes/browser.js | 37 +++ operator-api/src/routes/bus.js | 26 ++ operator-api/src/routes/clipboard.js | 72 ++++ operator-api/src/routes/fs.js | 192 +++++++++++ operator-api/src/routes/health.js | 6 + operator-api/src/routes/input.js | 147 +++++++++ operator-api/src/routes/logs.js | 36 ++ operator-api/src/routes/macros.js | 41 +++ operator-api/src/routes/metrics.js | 34 ++ operator-api/src/routes/network.js | 78 +++++ operator-api/src/routes/os.js | 20 ++ operator-api/src/routes/pipe.js | 31 ++ operator-api/src/routes/process.js | 91 +++++ operator-api/src/routes/recording.js | 66 ++++ operator-api/src/routes/screenshot.js | 71 ++++ operator-api/src/routes/scripts.js | 85 +++++ operator-api/src/routes/stream.js | 39 +++ operator-api/src/services/forwardService.js | 25 ++ operator-api/src/services/interceptService.js | 75 +++++ operator-api/src/services/recordingService.js | 83 +++++ operator-api/src/services/socksService.js | 28 ++ operator-api/src/services/streamService.js | 70 ++++ operator-api/src/utils/base64.js | 3 + operator-api/src/utils/env.js | 16 + operator-api/src/utils/exec.js | 17 + operator-api/src/utils/ids.js | 3 + operator-api/src/utils/sse.js | 13 + operator-api/tests/browser.test.js | 15 + operator-api/tests/browser/_pipe/_bus.test.js | 30 ++ operator-api/tests/bus.test.js | 12 + operator-api/tests/clipboard.test.js | 9 + operator-api/tests/fs.test.js | 54 +++ operator-api/tests/globalSetup.mjs | 45 +++ operator-api/tests/health.test.js | 10 + operator-api/tests/input.test.js | 9 + operator-api/tests/logs.test.js | 8 + operator-api/tests/macros.test.js | 22 ++ operator-api/tests/metrics.test.js | 15 + operator-api/tests/network.test.js | 31 ++ operator-api/tests/os.test.js | 20 ++ operator-api/tests/pipe.test.js | 11 + operator-api/tests/process.test.js | 32 ++ operator-api/tests/recording.test.js | 24 ++ operator-api/tests/screenshot.test.js | 14 + operator-api/tests/scripts.test.js | 28 ++ operator-api/tests/stream.test.js | 17 + operator-api/tests/utils.js | 67 ++++ operator-api/vitest.config.mjs | 13 + shared/build-operator-api.sh | 4 + 65 files changed, 2569 insertions(+), 220 deletions(-) create mode 100644 images/chromium-headful/daemon.conf create mode 100644 images/chromium-headful/dbus-mpris.conf create mode 100644 images/chromium-headful/dbus-pulseaudio.conf create mode 100644 images/chromium-headful/default.pa create mode 100644 operator-api/.env.example create mode 100644 operator-api/.gitignore create mode 100644 operator-api/README.md create mode 100644 operator-api/README.tests.md create mode 100644 operator-api/index.js create mode 100644 operator-api/package.json create mode 100644 operator-api/src/app.js create mode 100644 operator-api/src/routes/browser.js create mode 100644 operator-api/src/routes/bus.js create mode 100644 operator-api/src/routes/clipboard.js create mode 100644 operator-api/src/routes/fs.js create mode 100644 operator-api/src/routes/health.js create mode 100644 operator-api/src/routes/input.js create mode 100644 operator-api/src/routes/logs.js create mode 100644 operator-api/src/routes/macros.js create mode 100644 operator-api/src/routes/metrics.js create mode 100644 operator-api/src/routes/network.js create mode 100644 operator-api/src/routes/os.js create mode 100644 operator-api/src/routes/pipe.js create mode 100644 operator-api/src/routes/process.js create mode 100644 operator-api/src/routes/recording.js create mode 100644 operator-api/src/routes/screenshot.js create mode 100644 operator-api/src/routes/scripts.js create mode 100644 operator-api/src/routes/stream.js create mode 100644 operator-api/src/services/forwardService.js create mode 100644 operator-api/src/services/interceptService.js create mode 100644 operator-api/src/services/recordingService.js create mode 100644 operator-api/src/services/socksService.js create mode 100644 operator-api/src/services/streamService.js create mode 100644 operator-api/src/utils/base64.js create mode 100644 operator-api/src/utils/env.js create mode 100644 operator-api/src/utils/exec.js create mode 100644 operator-api/src/utils/ids.js create mode 100644 operator-api/src/utils/sse.js create mode 100644 operator-api/tests/browser.test.js create mode 100644 operator-api/tests/browser/_pipe/_bus.test.js create mode 100644 operator-api/tests/bus.test.js create mode 100644 operator-api/tests/clipboard.test.js create mode 100644 operator-api/tests/fs.test.js create mode 100644 operator-api/tests/globalSetup.mjs create mode 100644 operator-api/tests/health.test.js create mode 100644 operator-api/tests/input.test.js create mode 100644 operator-api/tests/logs.test.js create mode 100644 operator-api/tests/macros.test.js create mode 100644 operator-api/tests/metrics.test.js create mode 100644 operator-api/tests/network.test.js create mode 100644 operator-api/tests/os.test.js create mode 100644 operator-api/tests/pipe.test.js create mode 100644 operator-api/tests/process.test.js create mode 100644 operator-api/tests/recording.test.js create mode 100644 operator-api/tests/screenshot.test.js create mode 100644 operator-api/tests/scripts.test.js create mode 100644 operator-api/tests/stream.test.js create mode 100644 operator-api/tests/utils.js create mode 100644 operator-api/vitest.config.mjs create mode 100644 shared/build-operator-api.sh diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 4be552d4..b33716d5 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -1,96 +1,114 @@ -# webrtc client +############################################################################### +# Stage 1 ───────────────────────────────────────────────────────────────────── +# Build WebRTC client frontend +############################################################################### FROM node:22-bullseye-slim AS client + WORKDIR /src COPY client/package*.json ./ RUN npm install COPY client/ . RUN npm run build -# xorg dependencies -FROM docker.io/ubuntu:22.04 AS xorg-deps + +############################################################################### +# Stage 2 ───────────────────────────────────────────────────────────────────── +# Build custom Xorg dummy video driver (xf86-video-dummy v0.3.8 + RandR patch) +# and the custom input driver (xf86-input-neko) +############################################################################### +FROM ubuntu:22.04 AS xorg-deps WORKDIR /xorg ENV DEBIAN_FRONTEND=noninteractive + + +# Build-time dependencies for Xorg modules RUN set -eux; \ apt-get update; \ apt-get install -y \ git gcc pkgconf autoconf automake libtool make xorg-dev xutils-dev \ && rm -rf /var/lib/apt/lists/*; + COPY xorg-deps/ /xorg/ -# build xf86-video-dummy v0.3.8 with RandR support -RUN set -eux; \ - cd xf86-video-dummy/v0.3.8; \ - patch -p1 < ../01_v0.3.8_xdummy-randr.patch; \ - autoreconf -v --install; \ - ./configure; \ - make -j$(nproc); \ - make install; -# build custom input driver -RUN set -eux; \ - cd xf86-input-neko; \ - ./autogen.sh --prefix=/usr; \ - ./configure; \ - make -j$(nproc); \ - make install; +# Build xf86-video-dummy v0.3.8 with RandR support +RUN cd xf86-video-dummy/v0.3.8 && \ + patch -p1 < ../01_v0.3.8_xdummy-randr.patch && \ + autoreconf -v --install && \ + ./configure && \ + make -j"$(nproc)" && \ + make install + +# Build custom input driver +RUN cd xf86-input-neko && \ + ./autogen.sh --prefix=/usr && \ + ./configure && \ + make -j"$(nproc)" && \ + make install + + +############################################################################### +# Stage 3 ───────────────────────────────────────────────────────────────────── +# Extract neko executable from upstream image +############################################################################### FROM ghcr.io/onkernel/neko/base:3.0.6-v1.0.1 AS neko -# ^--- now has event.SYSTEM_PONG with legacy support to keepalive + + +############################################################################### +# Stage 4 ───────────────────────────────────────────────────────────────────── +# Final runtime image +############################################################################### FROM docker.io/ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_PRIORITY=high -RUN apt-get update && \ - apt-get -y upgrade && \ +############################################################################### +# Base OS & core toolchain & apps +############################################################################### +RUN set -eux; \ + apt-get update; \ + apt-get -y upgrade; \ apt-get -y install \ - # UI Requirements - xvfb \ - xterm \ - xdotool \ - scrot \ - imagemagick \ - sudo \ - mutter \ - x11vnc \ - # Python/pyenv reqs - build-essential \ - libssl-dev \ - zlib1g-dev \ - libbz2-dev \ - libreadline-dev \ - libsqlite3-dev \ - curl \ - git \ - libncursesw5-dev \ - xz-utils \ - tk-dev \ - libxml2-dev \ - libxmlsec1-dev \ - libffi-dev \ - liblzma-dev \ - # Network tools - net-tools \ - netcat \ - # PPA req - software-properties-common && \ - # Userland apps - sudo add-apt-repository ppa:mozillateam/ppa && \ + xvfb xterm xdotool scrot imagemagick sudo mutter x11vnc upower \ + build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ + curl git libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev \ + net-tools netcat software-properties-common; \ + \ + sudo add-apt-repository ppa:mozillateam/ppa; \ sudo apt-get install -y --no-install-recommends \ - chromium-browser \ - libreoffice \ - x11-apps \ - xpdf \ - gedit \ - xpaint \ - tint2 \ - galculator \ - pcmanfm \ - wget \ - xdg-utils \ - libvulkan1 \ - fonts-liberation \ - unzip && \ + chromium-browser libreoffice x11-apps xpdf gedit xpaint tint2 galculator pcmanfm \ + wget xdg-utils libvulkan1 fonts-liberation unzip; \ apt-get clean + +############################################################################### +# Locales & fonts +############################################################################### +RUN apt-get update && \ + apt-get install -y --no-install-recommends locales && \ + printf "%s\n" \ + "en_US.UTF-8 UTF-8" "en_GB.UTF-8 UTF-8" "es_ES.UTF-8 UTF-8" \ + "es_MX.UTF-8 UTF-8" "fr_FR.UTF-8 UTF-8" "de_DE.UTF-8 UTF-8" \ + "it_IT.UTF-8 UTF-8" "pt_PT.UTF-8 UTF-8" "pt_BR.UTF-8 UTF-8" \ + "nl_NL.UTF-8 UTF-8" "sv_SE.UTF-8 UTF-8" "no_NO.UTF-8 UTF-8" \ + "da_DK.UTF-8 UTF-8" "fi_FI.UTF-8 UTF-8" "tr_TR.UTF-8 UTF-8" \ + "vi_VN.UTF-8 UTF-8" "id_ID.UTF-8 UTF-8" "bn_IN.UTF-8 UTF-8" \ + "pa_IN.UTF-8 UTF-8" "zh_CN.UTF-8 UTF-8" "zh_TW.UTF-8 UTF-8" \ + "ja_JP.UTF-8 UTF-8" "ko_KR.UTF-8 UTF-8" "ar_SA.UTF-8 UTF-8" \ + "hi_IN.UTF-8 UTF-8" "ru_RU.UTF-8 UTF-8" "th_TH.UTF-8 UTF-8" \ + "el_GR.UTF-8 UTF-8" "he_IL.UTF-8 UTF-8" \ + > /etc/locale.gen && \ + locale-gen && \ + update-locale LANG=en_US.UTF-8 && \ + apt-get install -y --no-install-recommends \ + fonts-noto-core fonts-noto-ui-core fonts-noto-cjk fonts-noto-cjk-extra && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + + +############################################################################### +# FFmpeg (static build, latest 7.x) +############################################################################### # install ffmpeg manually since the version available in apt is from the 4.x branch due to #drama. # as of writing these static builds will be the latest 7.0.x release. RUN set -eux; \ @@ -102,72 +120,133 @@ RUN set -eux; \ install -m755 /tmp/ffmpeg-*/ffprobe /usr/local/bin/ffprobe; \ rm -rf /tmp/ffmpeg* -# runtime -ENV USERNAME=root -RUN set -eux; \ - apt-get update; \ + +############################################################################### +# Runtime dependencies, libxcvt, user creation, directory setup +############################################################################### +ARG KERNEL_USER=kernel +ARG KERNEL_UID=1000 +ARG KERNEL_GID=$KERNEL_UID + +RUN apt-get update && \ apt-get install -y --no-install-recommends \ - wget ca-certificates python2 supervisor xclip xdotool \ - pulseaudio dbus-x11 xserver-xorg-video-dummy \ - libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 \ - gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ - gstreamer1.0-pulseaudio gstreamer1.0-omx; \ - # - # install libxcvt0 (not available in debian:bullseye) - ARCH=$(dpkg --print-architecture); \ - wget http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_${ARCH}.deb; \ - apt-get install --no-install-recommends ./libxcvt0_0.1.2-1_${ARCH}.deb; \ - rm ./libxcvt0_0.1.2-1_${ARCH}.deb; \ - # + wget ca-certificates python2 supervisor xclip xdotool \ + pulseaudio dbus-x11 xserver-xorg-video-dummy rtkit \ + libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ + gstreamer1.0-pulseaudio gstreamer1.0-omx && \ + \ + # libxcvt0 (Debian package, not in Ubuntu repo) + ARCH=$(dpkg --print-architecture) && \ + curl -fsSL http://ftp.de.debian.org/debian/pool/main/libx/libxcvt/libxcvt0_0.1.2-1_${ARCH}.deb \ + -o /tmp/libxcvt.deb && \ + apt-get install -y --no-install-recommends /tmp/libxcvt.deb && \ + rm /tmp/libxcvt.deb && \ + \ + # Non-root user with audio/video privileges + groupadd --gid "$KERNEL_GID" "$KERNEL_USER" && \ + useradd --uid "$KERNEL_UID" --gid "$KERNEL_GID" \ + --shell /bin/bash --create-home "$KERNEL_USER" && \ + for g in audio video pulse pulse-access; do adduser "$KERNEL_USER" "$g"; done && \ + \ # workaround for an X11 problem: http://blog.tigerteufel.de/?p=476 - mkdir /tmp/.X11-unix; \ - chmod 1777 /tmp/.X11-unix; \ - chown $USERNAME /tmp/.X11-unix/; \ - # - # make directories for neko + mkdir -p /tmp/.X11-unix && \ + chmod 1777 /tmp/.X11-unix && \ + chown "$KERNEL_USER:$KERNEL_USER" /tmp/.X11-unix && \ + \ + # Make directories for neko mkdir -p /etc/neko /var/www /var/log/neko \ - /tmp/runtime-$USERNAME \ - /home/$USERNAME/.config/pulse \ - /home/$USERNAME/.local/share/xorg; \ - chmod 1777 /var/log/neko; \ - chown $USERNAME /var/log/neko/ /tmp/runtime-$USERNAME; \ - chown -R $USERNAME:$USERNAME /home/$USERNAME; \ - # clean up - apt-get clean -y; \ + /tmp/runtime-"$KERNEL_USER" \ + /home/"$KERNEL_USER"/.config \ + /home/"$KERNEL_USER"/.local/share/xorg && \ + chmod 1777 /var/log/neko && \ + chown -R "$KERNEL_USER:$KERNEL_USER" \ + /var/log/neko /tmp/runtime-"$KERNEL_USER" /home/"$KERNEL_USER" && \ + chmod 777 /etc/pulse || true && \ + apt-get clean && \ rm -rf /var/lib/apt/lists/* /var/cache/apt/ -# install chromium & ncat for proxying the remote debugging port -RUN add-apt-repository -y ppa:xtradeb/apps -RUN apt update -y && apt install -y chromium ncat -# Install noVNC +############################################################################### +# Chromium (via xtradeb) & ncat +############################################################################### +RUN add-apt-repository -y ppa:xtradeb/apps && \ + apt-get update && \ + apt-get install -y --no-install-recommends chromium ncat && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + + +############################################################################### +# noVNC (v1.5.0) + Websockify (v0.12.0) +############################################################################### RUN git clone --branch v1.5.0 https://github.com/novnc/noVNC.git /opt/noVNC && \ git clone --branch v0.12.0 https://github.com/novnc/websockify /opt/noVNC/utils/websockify && \ ln -s /opt/noVNC/vnc.html /opt/noVNC/index.html -# setup desktop env & app -ENV DISPLAY_NUM=1 -ENV HEIGHT=768 -ENV WIDTH=1024 -ENV WITHDOCKER=true -COPY xorg.conf /etc/neko/xorg.conf -COPY neko.yaml /etc/neko/neko.yaml -COPY --from=neko /usr/bin/neko /usr/bin/neko -COPY --from=client /src/dist/ /var/www -COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/xorg/modules/drivers/dummy_drv.so -COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so +############################################################################### +# Environment +############################################################################### +ENV DISPLAY_NUM=1 \ + HEIGHT=768 \ + WIDTH=1024 \ + WITHDOCKER=true + +############################################################################### +# Copy build artefacts & configurations +############################################################################### +COPY xorg.conf /etc/neko/xorg.conf +COPY default.pa /etc/pulse/default.pa +COPY daemon.conf /etc/pulse/daemon.conf +COPY dbus-pulseaudio.conf /etc/dbus-1/system.d/pulseaudio.conf +COPY dbus-mpris.conf /etc/dbus-1/system.d/mpris.conf +COPY neko.yaml /etc/neko/neko.yaml + +# Executables & drivers from previous stages +COPY --from=neko /usr/bin/neko /usr/bin/neko +COPY --from=client /src/dist/ /var/www +COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so \ + /usr/lib/xorg/modules/drivers/dummy_drv.so +COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so \ + /usr/lib/xorg/modules/input/neko_drv.so + +RUN chown -R "$KERNEL_USER:$KERNEL_USER" /var/www /etc/neko + + +############################################################################### +# Additional assets & wrapper +############################################################################### COPY image-chromium/ / -COPY ./wrapper.sh /wrapper.sh +COPY wrapper.sh /wrapper.sh -# copy the kernel-images API binary built externally + +############################################################################### +# Copy the kernel-images API binary built externally +############################################################################### COPY bin/kernel-images-api /usr/local/bin/kernel-images-api ENV WITH_KERNEL_IMAGES_API=false -RUN useradd -m -s /bin/bash kernel + +############################################################################### +# Set user data +############################################################################### RUN cp -r ./user-data /home/kernel/user-data -ENTRYPOINT [ "/wrapper.sh" ] +############################################################################### +# Runtime environment variables +############################################################################### +ENV USER=kernel \ + XDG_RUNTIME_DIR=/tmp/runtime-kernel \ + PULSE_SERVER=unix:${XDG_RUNTIME_DIR}/pulse/native \ + XDG_CONFIG_HOME=/tmp/.chromium \ + XDG_CACHE_HOME=/tmp/.chromium + + +############################################################################### +# Entrypoint +############################################################################### +ENTRYPOINT ["/wrapper.sh"] \ No newline at end of file diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 4c4fe0f8..ecbcb263 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -1,6 +1,6 @@ diff --git a/images/chromium-headful/client/public/kernel.svg b/images/chromium-headful/client/public/kernel.svg new file mode 100644 index 00000000..b9a86da4 --- /dev/null +++ b/images/chromium-headful/client/public/kernel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/chromium-headful/client/public/site.webmanifest b/images/chromium-headful/client/public/site.webmanifest index 03c2b31f..4d1b7924 100644 --- a/images/chromium-headful/client/public/site.webmanifest +++ b/images/chromium-headful/client/public/site.webmanifest @@ -13,7 +13,7 @@ "type": "image/png" } ], - "theme_color": "#19bd9c", - "background_color": "#19bd9c", + "theme_color": "#7B42F6", + "background_color": "#7B42F6", "display": "standalone" } diff --git a/images/chromium-headful/client/src/assets/styles/_variables.scss b/images/chromium-headful/client/src/assets/styles/_variables.scss index f74ac679..2f1fb4b4 100644 --- a/images/chromium-headful/client/src/assets/styles/_variables.scss +++ b/images/chromium-headful/client/src/assets/styles/_variables.scss @@ -21,10 +21,9 @@ $background-modifier-accent: hsla(0, 0%, 100%, 0.06); $elevation-low: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05); $elevation-high: 0 8px 16px rgba(0, 0, 0, 0.24); -$style-primary: #19bd9c; +$style-primary: #7B42F6; $style-error: #d32f2f; $menu-height: 40px; $controls-height: 125px; $side-width: 400px; - diff --git a/images/chromium-headful/client/src/components/connect.vue b/images/chromium-headful/client/src/components/connect.vue index c850c642..2d175244 100644 --- a/images/chromium-headful/client/src/components/connect.vue +++ b/images/chromium-headful/client/src/components/connect.vue @@ -11,8 +11,7 @@
-
-
+
@@ -103,40 +102,25 @@ .loader { width: 90px; height: 90px; - position: relative; margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; - .bounce1, - .bounce2 { + .spinning-logo { width: 100%; height: 100%; - border-radius: 50%; - background-color: $style-primary; - opacity: 0.6; - position: absolute; - top: 0; - left: 0; - - -webkit-animation: bounce 2s infinite ease-in-out; - animation: bounce 2s infinite ease-in-out; - } - - .bounce2 { - -webkit-animation-delay: -1s; - animation-delay: -1s; + animation: spin 2s linear infinite; } } } - @keyframes bounce { - 0%, - 100% { - transform: scale(0); - -webkit-transform: scale(0); + @keyframes spin { + from { + transform: rotate(0deg); } - 50% { - transform: scale(1); - -webkit-transform: scale(1); + to { + transform: rotate(360deg); } } } From ab2bea79197efe7a44a08d9bfc02fd9e1d548c1e Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 16:21:43 +0100 Subject: [PATCH 64/70] Operator API [unikraft build] --- images/chromium-headful/Kraftfile.erofs | 12 +++ images/chromium-headful/Kraftfile.no-erofs | 12 +++ images/chromium-headful/build-unikernel.sh | 94 +++++++++++++++------- 3 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 images/chromium-headful/Kraftfile.erofs create mode 100644 images/chromium-headful/Kraftfile.no-erofs diff --git a/images/chromium-headful/Kraftfile.erofs b/images/chromium-headful/Kraftfile.erofs new file mode 100644 index 00000000..18af1a0b --- /dev/null +++ b/images/chromium-headful/Kraftfile.erofs @@ -0,0 +1,12 @@ +spec: v0.6 + +runtime: index.unikraft.io/official/base-compat:latest + +labels: + cloud.unikraft.v1.instances/scale_to_zero.policy: "idle" + cloud.unikraft.v1.instances/scale_to_zero.stateful: "true" + cloud.unikraft.v1.instances/scale_to_zero.cooldown_time_ms: 5000 + +rootfs: ./initrd + +cmd: ["/wrapper.sh"] diff --git a/images/chromium-headful/Kraftfile.no-erofs b/images/chromium-headful/Kraftfile.no-erofs new file mode 100644 index 00000000..35a3d34b --- /dev/null +++ b/images/chromium-headful/Kraftfile.no-erofs @@ -0,0 +1,12 @@ +spec: v0.6 + +runtime: index.unikraft.io/official/base-compat:latest + +labels: + cloud.unikraft.v1.instances/scale_to_zero.policy: "idle" + cloud.unikraft.v1.instances/scale_to_zero.stateful: "true" + cloud.unikraft.v1.instances/scale_to_zero.cooldown_time_ms: 5000 + +rootfs: ./Dockerfile + +cmd: ["/wrapper.sh"] diff --git a/images/chromium-headful/build-unikernel.sh b/images/chromium-headful/build-unikernel.sh index 0b16bf99..deb4ee62 100755 --- a/images/chromium-headful/build-unikernel.sh +++ b/images/chromium-headful/build-unikernel.sh @@ -1,37 +1,73 @@ #!/usr/bin/env bash +# Flag to control whether to use EROFS or not +EROFS_DISABLE=${EROFS_DISABLE:-false} + # Move to the script's directory so relative paths work regardless of the caller CWD SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) cd "$SCRIPT_DIR" source "$SCRIPT_DIR/../../shared/ensure-common-build-run-vars.sh" chromium-headful -source "$SCRIPT_DIR/../../shared/erofs-utils.sh" -# Ensure the mkfs.erofs tool is available -if ! check_mkfs_erofs; then - echo "mkfs.erofs is not installed. Installing erofs-utils..." - install_erofs_utils -fi +# Copy the appropriate Kraftfile based on EROFS_DISABLE flag +if [ "$EROFS_DISABLE" = "false" ]; then + echo "Using EROFS configuration (default)..." + cp Kraftfile.erofs Kraftfile + source "$SCRIPT_DIR/../../shared/erofs-utils.sh" + + # Ensure the mkfs.erofs tool is available + if ! check_mkfs_erofs; then + echo "mkfs.erofs is not installed. Installing erofs-utils..." + install_erofs_utils + fi + + set -euo pipefail + + # Build the root file system + source ../../shared/start-buildkit.sh + + rm -rf ./.rootfs || true + + # Build the API binary + source ../../shared/build-server.sh "$(pwd)/bin" -set -euo pipefail - -# Build the root file system -source ../../shared/start-buildkit.sh -rm -rf ./.rootfs || true -# Build the API binary -source ../../shared/build-server.sh "$(pwd)/bin" -app_name=chromium-headful-build -docker build --platform linux/amd64 -t "$IMAGE" . -docker rm cnt-"$app_name" || true -docker create --platform linux/amd64 --name cnt-"$app_name" "$IMAGE" /bin/sh -docker cp cnt-"$app_name":/ ./.rootfs -rm -f initrd || true -sudo mkfs.erofs --all-root -d2 -E noinline_data -b 4096 initrd ./.rootfs - -# Package the unikernel (and the new initrd) to KraftCloud -kraft pkg \ - --name $UKC_INDEX/$IMAGE \ - --plat kraftcloud \ - --arch x86_64 \ - --strategy overwrite \ - --push \ - . + # Build operator api + test + .env → ./bin + source ../../shared/build-operator-api.sh "$(pwd)/bin" + + app_name=chromium-headful-build + docker build --platform linux/amd64 -t "$IMAGE" . + docker rm cnt-"$app_name" || true + docker create --platform linux/amd64 --name cnt-"$app_name" "$IMAGE" /bin/sh + docker cp cnt-"$app_name":/ ./.rootfs + rm -f initrd || true + sudo mkfs.erofs --all-root -d2 -E noinline_data -b 4096 initrd ./.rootfs + + # Package the unikernel (and the new initrd) to KraftCloud + kraft pkg \ + --name $UKC_INDEX/$IMAGE \ + --plat kraftcloud \ + --arch x86_64 \ + --strategy overwrite \ + --push \ + . +else + echo "Using non-EROFS configuration..." + cp Kraftfile.no-erofs Kraftfile + + set -euo pipefail + + source ../../shared/start-buildkit.sh + + # Build the API binary + source ../../shared/build-server.sh "$(pwd)/bin" + + # Build operator api + test + .env → ./bin + source ../../shared/build-operator-api.sh "$(pwd)/bin" + + # Package the unikernel to KraftCloud + kraft pkg \ + --name $UKC_INDEX/$IMAGE \ + --plat kraftcloud --arch x86_64 \ + --strategy overwrite \ + --push \ + . +fi From 53f46783e8198c386d09e62b08ab6df4a3780e09 Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 16:45:29 +0100 Subject: [PATCH 65/70] Operator API [unikraft build debug? ; added chrome ext install route in operator] --- images/chromium-headful/Kraftfile.no-erofs | 6 +- operator-api/bun.lock | 344 ++++++++++++++- operator-api/package.json | 6 +- operator-api/src/app.js | 2 + operator-api/src/routes/browser-ext.js | 481 +++++++++++++++++++++ operator-api/test.js | 14 + 6 files changed, 845 insertions(+), 8 deletions(-) create mode 100644 operator-api/src/routes/browser-ext.js diff --git a/images/chromium-headful/Kraftfile.no-erofs b/images/chromium-headful/Kraftfile.no-erofs index 35a3d34b..9364e423 100644 --- a/images/chromium-headful/Kraftfile.no-erofs +++ b/images/chromium-headful/Kraftfile.no-erofs @@ -3,10 +3,10 @@ spec: v0.6 runtime: index.unikraft.io/official/base-compat:latest labels: - cloud.unikraft.v1.instances/scale_to_zero.policy: "idle" + cloud.unikraft.v1.instances/scale_to_zero.policy: "on" cloud.unikraft.v1.instances/scale_to_zero.stateful: "true" - cloud.unikraft.v1.instances/scale_to_zero.cooldown_time_ms: 5000 + cloud.unikraft.v1.instances/scale_to_zero.cooldown_time_ms: 4000 rootfs: ./Dockerfile -cmd: ["/wrapper.sh"] +cmd: ["/wrapper.sh"] \ No newline at end of file diff --git a/operator-api/bun.lock b/operator-api/bun.lock index 40ebb7b9..f9844c70 100644 --- a/operator-api/bun.lock +++ b/operator-api/bun.lock @@ -4,14 +4,352 @@ "": { "name": "kernel-operator-api", "dependencies": { - "dotenv": "^17.2.1", - "hono": "^4.9.0", + "@hono/node-server": "^1.13.8", + "busboy": "^1.6.0", + "chalk": "^5.3.0", + "chokidar": "^3.6.0", + "dotenv": "^16.4.5", + "extract-zip": "^2.0.1", + "formdata-node": "^6.0.3", + "hono": "^4.5.10", + "http-proxy": "^1.18.1", + "httpolyglot": "^0.1.2", + "mime-types": "^2.1.35", + "morgan": "^1.10.0", + "nanoid": "^5.0.7", + "pidusage": "^3.0.2", + "uuid": "^11.1.0", + "ws": "^8.18.3", + }, + "devDependencies": { + "cross-env": "^7.0.3", + "nodemon": "^3.1.7", + "undici": "^7.13.0", + "vitest": "^2.0.5", }, }, }, "packages": { - "dotenv": ["dotenv@17.2.1", "", {}, "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@hono/node-server": ["@hono/node-server@1.18.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-O3puG/b7owYYmoQ2XPBf3SxBz6Dhk5VmWFhbaBU8/5wcUaXUPS0goxaI2Zfyg+Cu14ILJHEU7IFRw7miFxuXxg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.2", "", { "os": "android", "cpu": "arm" }, "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.46.2", "", { "os": "android", "cpu": "arm64" }, "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.46.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.46.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.46.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.46.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.46.2", "", { "os": "linux", "cpu": "arm" }, "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.46.2", "", { "os": "linux", "cpu": "arm" }, "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.46.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.46.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.46.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.46.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.46.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.46.2", "", { "os": "linux", "cpu": "x64" }, "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.46.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.46.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.46.2", "", { "os": "win32", "cpu": "x64" }, "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="], + + "chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="], + + "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "formdata-node": ["formdata-node@6.0.3", "", {}, "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "hono": ["hono@4.9.0", "", {}, "sha512-JAUc4Sqi3lhby2imRL/67LMcJFKiCu7ZKghM7iwvltVZzxEC5bVJCsAa4NTnSfmWGb+N2eOVtFE586R+K3fejA=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "httpolyglot": ["httpolyglot@0.1.2", "", {}, "sha512-ouHI1AaQMLgn4L224527S5+vq6hgvqPteurVfbm7ChViM3He2Wa8KP1Ny7pTYd7QKnDSPKcN8JYfC8r/lmsE3A=="], + + "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], + + "nodemon": ["nodemon@3.1.10", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pidusage": ["pidusage@3.0.2", "", { "dependencies": { "safe-buffer": "^5.2.1" } }, "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "rollup": ["rollup@4.46.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.2", "@rollup/rollup-android-arm64": "4.46.2", "@rollup/rollup-darwin-arm64": "4.46.2", "@rollup/rollup-darwin-x64": "4.46.2", "@rollup/rollup-freebsd-arm64": "4.46.2", "@rollup/rollup-freebsd-x64": "4.46.2", "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", "@rollup/rollup-linux-arm-musleabihf": "4.46.2", "@rollup/rollup-linux-arm64-gnu": "4.46.2", "@rollup/rollup-linux-arm64-musl": "4.46.2", "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", "@rollup/rollup-linux-ppc64-gnu": "4.46.2", "@rollup/rollup-linux-riscv64-gnu": "4.46.2", "@rollup/rollup-linux-riscv64-musl": "4.46.2", "@rollup/rollup-linux-s390x-gnu": "4.46.2", "@rollup/rollup-linux-x64-gnu": "4.46.2", "@rollup/rollup-linux-x64-musl": "4.46.2", "@rollup/rollup-win32-arm64-msvc": "4.46.2", "@rollup/rollup-win32-ia32-msvc": "4.46.2", "@rollup/rollup-win32-x64-msvc": "4.46.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], + + "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], + + "undici": ["undici@7.13.0", "", {}, "sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], } } diff --git a/operator-api/package.json b/operator-api/package.json index 5938ffef..36775487 100644 --- a/operator-api/package.json +++ b/operator-api/package.json @@ -19,6 +19,7 @@ "chalk": "^5.3.0", "chokidar": "^3.6.0", "dotenv": "^16.4.5", + "extract-zip": "^2.0.1", "formdata-node": "^6.0.3", "hono": "^4.5.10", "http-proxy": "^1.18.1", @@ -27,12 +28,13 @@ "morgan": "^1.10.0", "nanoid": "^5.0.7", "pidusage": "^3.0.2", - "uuid": "^9.0.1" + "uuid": "^11.1.0", + "ws": "^8.18.3" }, "devDependencies": { "cross-env": "^7.0.3", "nodemon": "^3.1.7", - "undici": "^6.19.8", + "undici": "^7.13.0", "vitest": "^2.0.5" } } diff --git a/operator-api/src/app.js b/operator-api/src/app.js index 9a6efc9f..ae76810f 100644 --- a/operator-api/src/app.js +++ b/operator-api/src/app.js @@ -16,6 +16,7 @@ import { osRouter } from './routes/os.js' import { browserRouter } from './routes/browser.js' import { pipeRouter } from './routes/pipe.js' import { healthRouter } from './routes/health.js' +import { browserExtRouter } from './routes/browser-ext.js' export const app = new Hono() @@ -36,3 +37,4 @@ app.route('/', osRouter) app.route('/', browserRouter) app.route('/', pipeRouter) app.route('/', healthRouter) +app.route('/', browserExtRouter) \ No newline at end of file diff --git a/operator-api/src/routes/browser-ext.js b/operator-api/src/routes/browser-ext.js new file mode 100644 index 00000000..5fe5d6a1 --- /dev/null +++ b/operator-api/src/routes/browser-ext.js @@ -0,0 +1,481 @@ +// src/routes/browser-ext.js +// ESM. Requires: hono, undici, extract-zip, ws +import { Hono } from 'hono' +import fs from 'node:fs/promises' +import fssync from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import crypto from 'node:crypto' +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' +import extract from 'extract-zip' +import { request } from 'undici' +import { WebSocket } from 'ws' + +export const browserExtRouter = new Hono() + +// POST /browser/extension/add/unpacked (multipart/form-data) +// fields: +// github_url: string OR +// archive_file: File (.zip, manifest at root or in first-level dir) +browserExtRouter.post('/browser/extension/add/unpacked', async (c) => { + const origin = new URL(c.req.url).origin + const form = await readForm(c) + + const params = { + github_url: form.github_url || undefined, + archive_file_path: form.archive_path || undefined, + chromiumBinary: process.env.CHROMIUM_BINARY || 'chromium', + devtoolsHost: process.env.CHROME_HOST || '127.0.0.1', + devtoolsPort: Number(process.env.CHROME_PORT || 9222), + policyDir: await detectPolicyDir([ + '/etc/chromium/policies/managed', + '/etc/opt/chromium/policies/managed', + '/etc/opt/chrome/policies/managed', + '/etc/chrome/policies/managed' + ]), + repoStorageDir: process.env.EXT_REPO_DIR || '/opt/extrepo', + repoBaseUrl: process.env.EXT_REPO_BASE_URL || `${origin}/extrepo`, + keyStoreDir: process.env.EXT_KEY_STORE_DIR || '/var/lib/chrome-ext-keys', + tryHotReloadPolicy: true, + fallbackRestart: true, + waitInstallTimeoutMs: 25000 + } + + try { + const result = await addUnpackedExtension(params) + return c.json(result, 201) + } catch (err) { + return c.json({ error: err.message || String(err) }, 500) + } finally { + if (form.cleanup) await form.cleanup().catch(() => { }) + } +}) + +// GET /extrepo/* (serves CRX and update.xml from repoStorageDir) +browserExtRouter.get('/extrepo/*', async (c) => { + const baseDir = process.env.EXT_REPO_DIR || '/opt/extrepo' + const tail = c.req.path.replace(/^\/extrepo\/?/, '') + const safePath = path.normalize(tail).replace(/^(\.\.[/\\])+/, '') + const target = path.join(baseDir, safePath) + if (!target.startsWith(path.resolve(baseDir))) return c.text('Forbidden', 403) + try { + const stat = await fs.stat(target) + if (stat.isDirectory()) return c.text('Not Found', 404) + const ext = path.extname(target).toLowerCase() + const type = + ext === '.xml' ? 'application/xml' : + ext === '.crx' ? 'application/x-chrome-extension' : + 'application/octet-stream' + const stream = fssync.createReadStream(target) + return new Response(stream, { headers: { 'content-type': type } }) + } catch { + return c.text('Not Found', 404) + } +}) + +/* ───────────────────────────── form reader ───────────────────────────── */ + +async function readForm(c) { + const ct = c.req.header('content-type') || '' + if (ct.includes('application/json')) { + const body = await c.req.json() + return { + github_url: body.github_url, + archive_path: undefined, + cleanup: async () => { } + } + } + + const fd = await c.req.formData() + const github_url = fd.get('github_url')?.toString() || undefined + const file = fd.get('archive_file') + let archive_path + let cleanup = async () => { } + + if (file && typeof file === 'object' && 'arrayBuffer' in file) { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'extupload-')) + archive_path = path.join(tmpRoot, 'upload.zip') + const buf = Buffer.from(await file.arrayBuffer()) + await fs.writeFile(archive_path, buf) + cleanup = async () => { try { await fs.rm(tmpRoot, { recursive: true, force: true }) } catch { } } + } + + return { github_url, archive_path, cleanup } +} + +/* ─────────────────────── core implementation ─────────────────────── */ + +const DEFAULTS = { + chromiumBinary: process.env.CHROMIUM_BINARY || 'chromium', + devtoolsHost: process.env.CHROME_HOST || '127.0.0.1', + devtoolsPort: Number(process.env.CHROME_PORT || 9222), + policyDir: '/etc/chromium/policies/managed', + repoStorageDir: process.env.EXT_REPO_DIR || '/opt/extrepo', + repoBaseUrl: process.env.EXT_REPO_BASE_URL || 'http://127.0.0.1:3000/extrepo', + keyStoreDir: process.env.EXT_KEY_STORE_DIR || '/var/lib/chrome-ext-keys', + userDataDirsProbe: [ + '/tmp/.chromium/chromium', + '/home/kernel/.config/chromium', + path.join(os.homedir(), '.config', 'chromium') + ] +} + +const NIBBLE_MAP = 'abcdefghijklmnop'.split('') + +async function addUnpackedExtension({ + github_url, + archive_file_path, + chromiumBinary = DEFAULTS.chromiumBinary, + devtoolsHost = DEFAULTS.devtoolsHost, + devtoolsPort = DEFAULTS.devtoolsPort, + policyDir = DEFAULTS.policyDir, + repoStorageDir = DEFAULTS.repoStorageDir, + repoBaseUrl = DEFAULTS.repoBaseUrl, + keyStoreDir = DEFAULTS.keyStoreDir, + tryHotReloadPolicy = true, + fallbackRestart = true, + waitInstallTimeoutMs = 25000 +} = {}) { + assertOneSource(github_url, archive_file_path) + + await ensureDir(repoStorageDir) + await ensureDir(policyDir) + await ensureDir(keyStoreDir) + + const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'extwork-')) + const srcZip = path.join(workRoot, 'src.zip') + const unpackDir = path.join(workRoot, 'unpacked') + + if (github_url) { + const zipUrl = await resolveGithubZipURL(github_url) + await downloadToFile(zipUrl, srcZip) + } else { + await fs.copyFile(archive_file_path, srcZip) + } + + await extract(srcZip, { dir: unpackDir }) + const extRoot = await resolveExtensionRoot(unpackDir) + const manifest = await readJson(path.join(extRoot, 'manifest.json')) + validateManifest(manifest) + + const sourceKeyId = await decideKeyId({ github_url, manifest }) + const pemPath = path.join(keyStoreDir, `${sourceKeyId}.pem`) + if (!fssync.existsSync(pemPath)) { + await ensureDir(keyStoreDir) + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) + await fs.writeFile(pemPath, pem, { mode: 0o600 }) + } + + const outCrx = path.join(workRoot, 'packed.crx') + await packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) + + const extId = await computeExtensionIdFromPem(pemPath) + + const publicDir = path.join(repoStorageDir, extId) + await ensureDir(publicDir) + const finalCrx = path.join(publicDir, `${extId}.crx`) + await fs.copyFile(outCrx, finalCrx) + const updateXmlPath = path.join(publicDir, 'update.xml') + + const codebaseUrl = `${trimSlash(repoBaseUrl)}/${extId}/${extId}.crx` + const updateUrl = `${trimSlash(repoBaseUrl)}/${extId}/update.xml` + await writeUpdateXml({ updateXmlPath, extId, version: manifest.version, codebaseUrl }) + + const policyPath = path.join(policyDir, `force_${extId}.json`) + await fs.writeFile( + policyPath, + JSON.stringify({ ExtensionInstallForcelist: [`${extId};${updateUrl}`] }, null, 2) + '\n', + { mode: 0o644 } + ) + + const userDataDir = await locateUserDataDir(DEFAULTS.userDataDirsProbe) + const extInstallDir = path.join(userDataDir, 'Default', 'Extensions', extId) + const installedBefore = await dirExists(extInstallDir) + + if (tryHotReloadPolicy) { + await devtoolsReloadPolicies({ devtoolsHost, devtoolsPort }).catch(() => { }) + } + + let installed = await waitForExtensionInstallOnDisk(extInstallDir, waitInstallTimeoutMs) + if (!installed && fallbackRestart) { + await devtoolsRestartBrowser({ devtoolsHost, devtoolsPort }).catch(() => { }) + await waitDevToolsUp({ devtoolsHost, devtoolsPort, timeoutMs: 20000 }).catch(() => { }) + installed = await waitForExtensionInstallOnDisk(extInstallDir, 15000) + } + + await safeRm(workRoot) + + return { + id: extId, + version: manifest.version, + crx_path: finalCrx, + update_xml_path: updateXmlPath, + update_url: updateUrl, + policy_path: policyPath, + installed: installed || installedBefore || false, + profile_extensions_dir: extInstallDir + } +} + +/* ────────────────────────────── helpers ────────────────────────────── */ + +function assertOneSource(github_url, archive_file_path) { + const provided = [!!github_url, !!archive_file_path].filter(Boolean).length + if (provided !== 1) throw new Error('Provide exactly one of github_url or archive_file') +} + +async function ensureDir(p) { + await fs.mkdir(p, { recursive: true }) +} + +async function safeRm(p) { + try { await fs.rm(p, { recursive: true, force: true }) } catch { } +} + +async function detectPolicyDir(candidates) { + for (const dir of candidates) { + try { + await fs.mkdir(dir, { recursive: true }) + const test = path.join(dir, '.write_test') + await fs.writeFile(test, 'x') + await fs.rm(test) + return dir + } catch { } + } + // last resort still return first path; write will error explicitly if not writable + return candidates[0] +} + +async function downloadToFile(url, outPath) { + let res = await request(url, { maxRedirections: 3 }).catch(() => null) + if (!res || res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`Download failed ${res ? res.statusCode : 'net'} for ${url}`) + } + const file = fssync.createWriteStream(outPath) + await new Promise((resolve, reject) => { + res.body.pipe(file) + res.body.on('error', reject) + file.on('finish', resolve) + }) +} + +async function resolveGithubZipURL(input) { + const u = new URL(input) + if (u.hostname !== 'github.com') throw new Error('github_url must be github.com') + const parts = u.pathname.split('/').filter(Boolean) + if (parts.length < 2) throw new Error('Invalid GitHub repo URL') + + if (parts.includes('archive') && parts.includes('refs') && parts.includes('heads')) { + return String(u) + } + + const treeIdx = parts.indexOf('tree') + let branch = null + if (treeIdx >= 0 && parts[treeIdx + 1]) branch = parts[treeIdx + 1] + + const [owner, repo] = parts + const tries = [] + if (branch) { + tries.push(`https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${branch}`) + } else { + tries.push(`https://codeload.github.com/${owner}/${repo}/zip/refs/heads/main`) + tries.push(`https://codeload.github.com/${owner}/${repo}/zip/refs/heads/master`) + } + + for (const url of tries) { + const head = await request(url, { method: 'HEAD' }).catch(() => null) + if (head && head.statusCode === 200) return url + } + return `https://codeload.github.com/${owner}/${repo}/zip/HEAD` +} + +async function resolveExtensionRoot(unpackedDir) { + if (await fileExists(path.join(unpackedDir, 'manifest.json'))) return unpackedDir + const entries = await fs.readdir(unpackedDir, { withFileTypes: true }) + const dirs = entries.filter((e) => e.isDirectory()) + if (dirs.length === 1) { + const cand = path.join(unpackedDir, dirs[0].name) + if (await fileExists(path.join(cand, 'manifest.json'))) return cand + } + for (const e of entries) { + if (!e.isDirectory()) continue + const cand = path.join(unpackedDir, e.name) + if (await fileExists(path.join(cand, 'manifest.json'))) return cand + } + throw new Error('manifest.json not found') +} + +async function readJson(p) { + const buf = await fs.readFile(p) + return JSON.parse(buf.toString('utf8')) +} + +function validateManifest(m) { + if (!m || typeof m !== 'object') throw new Error('Invalid manifest.json') + if (!m.manifest_version) throw new Error('manifest_version missing') + if (m.manifest_version !== 3) throw new Error('Only Manifest V3 is supported') + if (!m.version) throw new Error('manifest version missing') + if (!/^\d+(\.\d+){0,3}$/.test(m.version)) throw new Error('manifest version must be dotted number') +} + +async function decideKeyId({ github_url, manifest }) { + if (github_url) { + const norm = github_url.replace(/\.git$/, '').toLowerCase() + return 'gh_' + crypto.createHash('sha256').update(norm).digest('hex').slice(0, 16) + } + const name = String(manifest.name || 'uploaded').toLowerCase() + return 'up_' + crypto.createHash('sha256').update(name).digest('hex').slice(0, 16) +} + +async function packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) { + const args = [`--pack-extension=${extRoot}`, `--pack-extension-key=${pemPath}`] + await execFileStrict(chromiumBinary, args, { env: { ...process.env, DISPLAY: process.env.DISPLAY || ':1' } }) + const produced = `${extRoot}.crx` + if (!fssync.existsSync(produced)) throw new Error('Chromium packer did not create .crx') + await fs.copyFile(produced, outCrx) +} + +async function computeExtensionIdFromPem(pemPath) { + const pem = await fs.readFile(pemPath, 'utf8') + const keyObj = crypto.createPrivateKey(pem) + const pub = crypto.createPublicKey(keyObj) + const spkiDer = pub.export({ type: 'spki', format: 'der' }) + const hash = crypto.createHash('sha256').update(spkiDer).digest() + const first16 = hash.subarray(0, 16) + let id = '' + for (const b of first16) id += NIBBLE_MAP[(b >> 4) & 0x0f] + NIBBLE_MAP[b & 0x0f] + return id +} + +async function writeUpdateXml({ updateXmlPath, extId, version, codebaseUrl }) { + const xml = + `\n` + + `\n` + + ` \n` + + ` \n` + + ` \n` + + `\n` + await fs.writeFile(updateXmlPath, xml, { mode: 0o644 }) +} + +function trimSlash(s) { return s.replace(/\/+$/, '') } + +async function execFileStrict(cmd, args = [], opts = {}) { + await new Promise((resolve, reject) => { + const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + let stderr = '' + p.stderr.on('data', (d) => { stderr += d.toString() }) + p.on('error', reject) + p.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}\n${stderr}`)) + }) + }) +} + +async function fileExists(p) { + try { await fs.access(p); return true } catch { return false } +} + +async function dirExists(p) { + try { const st = await fs.stat(p); return st.isDirectory() } catch { return false } +} + +async function locateUserDataDir(candidates) { + for (const cand of candidates) if (await dirExists(cand)) return cand + return candidates[0] +} + +/* ───────── DevTools helpers ───────── */ + +async function getBrowserWsUrl({ devtoolsHost, devtoolsPort }) { + const url = `http://${devtoolsHost}:${devtoolsPort}/json/version` + const res = await request(url) + if (res.statusCode !== 200) throw new Error('DevTools not reachable') + const data = await res.body.json() + if (!data.webSocketDebuggerUrl) throw new Error('webSocketDebuggerUrl missing') + return data.webSocketDebuggerUrl +} + +async function cdpSession(wsUrl) { + const ws = new WebSocket(wsUrl, { perMessageDeflate: false }) + await new Promise((resolve, reject) => { + ws.once('open', resolve) + ws.once('error', reject) + }) + let id = 0 + const pending = new Map() + ws.on('message', (data) => { + const msg = JSON.parse(String(data)) + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id) + pending.delete(msg.id) + if (msg.error) reject(new Error(msg.error.message || 'CDP error')) + else resolve(msg.result) + } + }) + function call(method, params) { + return new Promise((resolve, reject) => { + const msg = { id: ++id, method, params } + pending.set(id, { resolve, reject }) + ws.send(JSON.stringify(msg), (err) => err && reject(err)) + }) + } + function close() { try { ws.close() } catch { } } + return { call, close, ws } +} + +async function devtoolsReloadPolicies({ devtoolsHost, devtoolsPort }) { + const wsUrl = await getBrowserWsUrl({ devtoolsHost, devtoolsPort }) + const cdp = await cdpSession(wsUrl) + try { + const { targetId } = await cdp.call('Target.createTarget', { url: 'chrome://policy' }) + const { sessionId } = await cdp.call('Target.attachToTarget', { targetId, flatten: true }) + await cdp.call('Runtime.enable', { sessionId }) + await cdp.call('Runtime.evaluate', { + sessionId, + expression: 'chrome && chrome.send ? chrome.send("reloadPolicies") : null', + awaitPromise: false + }) + await delay(1000) + await cdp.call('Target.closeTarget', { targetId }) + } finally { + cdp.close() + } +} + +async function devtoolsRestartBrowser({ devtoolsHost, devtoolsPort }) { + const wsUrl = await getBrowserWsUrl({ devtoolsHost, devtoolsPort }) + const cdp = await cdpSession(wsUrl) + try { + await cdp.call('Target.createTarget', { url: 'chrome://restart' }) + } finally { + cdp.close() + } +} + +async function waitDevToolsUp({ devtoolsHost, devtoolsPort, timeoutMs }) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const wsUrl = await getBrowserWsUrl({ devtoolsHost, devtoolsPort }) + if (wsUrl) return true + } catch { } + await delay(500) + } + return false +} + +async function waitForExtensionInstallOnDisk(extInstallDir, timeoutMs) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (await dirExists(extInstallDir)) { + const subs = await fs.readdir(extInstallDir).catch(() => []) + if (subs.length > 0) return true + } + await delay(500) + } + return false +} diff --git a/operator-api/test.js b/operator-api/test.js index 92b4780f..b5f485da 100644 --- a/operator-api/test.js +++ b/operator-api/test.js @@ -513,11 +513,25 @@ async function suite_stream() { } }, { skipIf: haveFFmpeg ? false : 'ffmpeg missing' }) } +async function suite_browser_ext() { + await runTest('browser-ext', 'add unpacked extension from GitHub', async () => { + const r = await j('/browser/extension/add/unpacked', { + method: 'POST', + body: JSON.stringify({ + github_url: 'https://github.com/SimGus/chrome-extension-v3-starter' + }) + }) + if (r.status !== 201) throw new Error(`bad status ${r.status}`) + if (!r.body.id || !r.body.version) throw new Error('missing extension details') + console.log(chalk.gray(`Extension ID: ${r.body.id}, Version: ${r.body.version}`)) + }) +} // ---------------------------- Runner ---------------------------- const SUITES = [ ['health', suite_health], // ['browser', suite_browser], + ['browser-ext', suite_browser_ext], ['bus', suite_bus], ['clipboard', suite_clipboard], ['fs', suite_fs], From a35013358e4582aae7591ba01a7bd303a193690d Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 17:38:14 +0100 Subject: [PATCH 66/70] Operator API [unikraft build erofs -b fix ; debugging uinikraft with more memory] --- images/chromium-headful/build-unikernel.sh | 6 ++++-- images/chromium-headful/run-unikernel.sh | 14 +++++++++++++- operator-api/.kernel-operator.env | 4 ++-- operator-api/.wipdev.example.env | 4 ++-- operator-api/src/routes/screenshot.js | 4 ++-- operator-api/src/services/recordingService.js | 4 ++-- operator-api/src/services/streamService.js | 4 ++-- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/images/chromium-headful/build-unikernel.sh b/images/chromium-headful/build-unikernel.sh index deb4ee62..c01ad14a 100755 --- a/images/chromium-headful/build-unikernel.sh +++ b/images/chromium-headful/build-unikernel.sh @@ -39,8 +39,10 @@ if [ "$EROFS_DISABLE" = "false" ]; then docker create --platform linux/amd64 --name cnt-"$app_name" "$IMAGE" /bin/sh docker cp cnt-"$app_name":/ ./.rootfs rm -f initrd || true - sudo mkfs.erofs --all-root -d2 -E noinline_data -b 4096 initrd ./.rootfs - + # sudo mkfs.erofs --all-root -d2 -E noinline_data -b 4096 initrd ./.rootfs + # default block size is 4096 and -b fails for some reason, removed it + sudo mkfs.erofs --all-root -d2 -E noinline_data initrd ./.rootfs + # Package the unikernel (and the new initrd) to KraftCloud kraft pkg \ --name $UKC_INDEX/$IMAGE \ diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index fd227859..101bbb60 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -41,8 +41,20 @@ kraft cloud volume import -s "$FLAGS_DIR" -v "$volume_name" trap 'rm -rf "$FLAGS_DIR"' EXIT +# ------------------------------------------------------------------------------ +# WARNING: MEMORY UPGRADE NOTICE +# ------------------------------------------------------------------------------ +echo -e "\033[1;41m" +echo -e " " +echo -e " DEBUG : KERNEL : run-unikernel.sh : TESTING WITH 16GB INSTEAD OF 8GB " +echo -e " AND SHOULD BE REPLACED BACK LATER " +echo -e " " +echo -e "\033[0m" + + deploy_args=( - -M 8192 + # -M 8192 # Previous memory allocation + -M 16384 # Doubled memory allocation -p 9222:9222/tls -e DISPLAY_NUM=1 -e HEIGHT=768 diff --git a/operator-api/.kernel-operator.env b/operator-api/.kernel-operator.env index c83eb99a..1364e37d 100644 --- a/operator-api/.kernel-operator.env +++ b/operator-api/.kernel-operator.env @@ -10,8 +10,8 @@ XDG_RUNTIME_DIR=/tmp FFMPEG_BIN=/usr/local/bin/ffmpeg XDOTOOL_BIN=xdotool -SCREEN_WIDTH=1024 -SCREEN_HEIGHT=768 +WIDTH=1024 +HEIGHT=768 HTTP_PROXY_PORT=8082 SOCKS5_BIND_HOST=127.0.0.1 diff --git a/operator-api/.wipdev.example.env b/operator-api/.wipdev.example.env index fe9c412b..11eff6eb 100644 --- a/operator-api/.wipdev.example.env +++ b/operator-api/.wipdev.example.env @@ -6,8 +6,8 @@ XDG_RUNTIME_DIR=/tmp FFMPEG_BIN=/usr/bin/ffmpeg XDOTOOL_BIN=/usr/bin/xdotool -SCREEN_WIDTH=1280 -SCREEN_HEIGHT=720 +WIDTH=1280 +HEIGHT=720 HTTP_PROXY_PORT=8082 SOCKS5_BIND_HOST=127.0.0.1 SOCKS5_BIND_PORT=1080 diff --git a/operator-api/src/routes/screenshot.js b/operator-api/src/routes/screenshot.js index 55747f29..29852dea 100644 --- a/operator-api/src/routes/screenshot.js +++ b/operator-api/src/routes/screenshot.js @@ -9,8 +9,8 @@ import { SCREENSHOTS_DIR } from '../utils/env.js' const FFMPEG = process.env.FFMPEG_BIN || '/usr/bin/ffmpeg' const DISPLAY = process.env.DISPLAY || ':0' -const SCREEN_WIDTH = Number(process.env.SCREEN_WIDTH || 1024) -const SCREEN_HEIGHT = Number(process.env.SCREEN_HEIGHT || 768) +const WIDTH = Number(process.env.WIDTH || 1024) +const HEIGHT = Number(process.env.HEIGHT || 768) if (DISPLAY == ':20') { console.warn(`DISPLAY from env: ${DISPLAY} [likely for debugging in a remote VM]`) diff --git a/operator-api/src/services/recordingService.js b/operator-api/src/services/recordingService.js index 490bcce5..01bb0967 100644 --- a/operator-api/src/services/recordingService.js +++ b/operator-api/src/services/recordingService.js @@ -6,8 +6,8 @@ import { RECORDINGS_DIR } from '../utils/env.js' import { uid } from '../utils/ids.js' const FFMPEG = process.env.FFMPEG_BIN || '/usr/bin/ffmpeg' -const SCREEN_WIDTH = Number(process.env.SCREEN_WIDTH || 1024) -const SCREEN_HEIGHT = Number(process.env.SCREEN_HEIGHT || 768) +const WIDTH = Number(process.env.WIDTH || 1024) +const HEIGHT = Number(process.env.HEIGHT || 768) const DISPLAY = process.env.DISPLAY || ':0' const state = new Map() // id -> {proc, file, started_at, finished_at} diff --git a/operator-api/src/services/streamService.js b/operator-api/src/services/streamService.js index 0bbb03f6..65840529 100644 --- a/operator-api/src/services/streamService.js +++ b/operator-api/src/services/streamService.js @@ -5,8 +5,8 @@ import { EventEmitter } from 'node:events' const FFMPEG = process.env.FFMPEG_BIN || '/usr/bin/ffmpeg' const DISPLAY = process.env.DISPLAY || ':0' -const SCREEN_WIDTH = Number(process.env.SCREEN_WIDTH || 1024) -const SCREEN_HEIGHT = Number(process.env.SCREEN_HEIGHT || 768) +const WIDTH = Number(process.env.WIDTH || 1024) +const HEIGHT = Number(process.env.HEIGHT || 768) const PULSE_SOURCE = process.env.PULSE_SOURCE || 'default' const streams = new Map() // id -> {proc, emitter} From d9e9677af9008c834ca0c63e8f770fab2db35f0c Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 18:42:50 +0100 Subject: [PATCH 67/70] =?UTF-8?q?Operator=20API=20[works=20in=20unikraft?= =?UTF-8?q?=20=F0=9F=A4=97=20;=20fix=20attempt=20for=20operator:chrome-ext?= =?UTF-8?q?-remote-install=20error=20(chromium=20--pack-extension=3D/tmp/e?= =?UTF-8?q?xtwork../unpacked/my-chrome-ext-main=20--pack-extension-key=3D/?= =?UTF-8?q?var/lib/chrome-ext-keys/gh=5Fe7...a.pem=20exited=201=20[ERROR:c?= =?UTF-8?q?ontent/browser/zygote=5Fhost/zygote=5Fhost...]=20Running=20as?= =?UTF-8?q?=20root=20without=20--no-sandbox=20is=20not=20supported.=20see?= =?UTF-8?q?=20https://crbug.com/638180)=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- images/chromium-headful/run-unikernel.sh | 15 +----- operator-api/README.md | 6 +++ operator-api/openapi.yaml | 66 ++++++++++++++++++++++++ operator-api/src/routes/browser-ext.js | 23 ++++++++- operator-api/test.js | 35 ++++++++++--- 5 files changed, 124 insertions(+), 21 deletions(-) diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index 101bbb60..16f7c6ac 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -40,21 +40,8 @@ kraft cloud volume import -s "$FLAGS_DIR" -v "$volume_name" # Ensure the temp directory is cleaned up on exit trap 'rm -rf "$FLAGS_DIR"' EXIT - -# ------------------------------------------------------------------------------ -# WARNING: MEMORY UPGRADE NOTICE -# ------------------------------------------------------------------------------ -echo -e "\033[1;41m" -echo -e " " -echo -e " DEBUG : KERNEL : run-unikernel.sh : TESTING WITH 16GB INSTEAD OF 8GB " -echo -e " AND SHOULD BE REPLACED BACK LATER " -echo -e " " -echo -e "\033[0m" - - deploy_args=( - # -M 8192 # Previous memory allocation - -M 16384 # Doubled memory allocation + -M 8192 -p 9222:9222/tls -e DISPLAY_NUM=1 -e HEIGHT=768 diff --git a/operator-api/README.md b/operator-api/README.md index f8054773..f5b095c4 100644 --- a/operator-api/README.md +++ b/operator-api/README.md @@ -52,6 +52,7 @@ bun build # binaries : dist/kernel-operator-api , dist/kernel-operator-test # after tests complete (some tests have ~30s timeout) # you should be able to fetch the gradually generated tests logs file # using the operator api itself, from outside the container (provided /fs/read_file works) + # (use `localhost:10001` if you want to try from inside the container's chromium) curl "http://localhost:444/health" # health check curl -o kernel_operator_tests.log "http://localhost:444/fs/read_file?path=%2Ftmp%2Fkernel-operator%2Ftests.log" cat kernel_operator_tests.log @@ -62,6 +63,11 @@ bun build # binaries : dist/kernel-operator-api , dist/kernel-operator-test `[✅ : Works , 〰️ : Yet to be tested , ❌ : Doesn't work]` +## /browser-ext +Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes +--- | --- | --- | --- | --- +/browser/extension/add/unpacked | 〰️ | 〰️ | 〰️ | Supports GitHub URL or zip file upload + ## /bus Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- diff --git a/operator-api/openapi.yaml b/operator-api/openapi.yaml index b8cc20ab..d8de4741 100644 --- a/operator-api/openapi.yaml +++ b/operator-api/openapi.yaml @@ -1817,6 +1817,72 @@ paths: "404": $ref: "#/components/responses/NotFoundError" + /browser/extension/add/unpacked: + post: + summary: Add an unpacked browser extension + operationId: browserExtensionAddUnpacked + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + github_url: + type: string + description: GitHub repository URL for the extension + archive_file: + type: string + format: binary + description: ZIP archive containing the extension (manifest at root or in first-level dir) + oneOf: + - required: [github_url] + - required: [archive_file] + responses: + "201": + description: Extension added successfully + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: Extension ID + version: + type: string + description: Extension version + crx_path: + type: string + description: Path to the CRX file + update_xml_path: + type: string + description: Path to the update XML file + update_url: + type: string + description: URL for extension updates + policy_path: + type: string + description: Path to the policy file + installed: + type: boolean + description: Whether the extension was installed + profile_extensions_dir: + type: string + description: Directory where the extension is installed + "400": + $ref: "#/components/responses/BadRequestError" + "500": + description: Server error + content: + application/json: + schema: + type: object + properties: + error: + type: string + + /pipe/send: post: summary: Send a JSON object onto a named pipe channel diff --git a/operator-api/src/routes/browser-ext.js b/operator-api/src/routes/browser-ext.js index 5fe5d6a1..f488ebf6 100644 --- a/operator-api/src/routes/browser-ext.js +++ b/operator-api/src/routes/browser-ext.js @@ -329,9 +329,30 @@ async function decideKeyId({ github_url, manifest }) { return 'up_' + crypto.createHash('sha256').update(name).digest('hex').slice(0, 16) } +async function lookupUser(name) { + const txt = await fs.readFile('/etc/passwd', 'utf8') + const line = txt.split('\n').find(l => l.startsWith(name + ':')) + if (!line) throw new Error(`User not found: ${name}`) + const parts = line.split(':') + return { uid: Number(parts[2]), gid: Number(parts[3]), home: parts[5] } +} + async function packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) { const args = [`--pack-extension=${extRoot}`, `--pack-extension-key=${pemPath}`] - await execFileStrict(chromiumBinary, args, { env: { ...process.env, DISPLAY: process.env.DISPLAY || ':1' } }) + const { uid, gid, home } = await lookupUser(process.env.PACK_AS_USER || 'kernel') + // ensure the packer can read the private key + try { await fs.chown(pemPath, uid, gid) } catch { } + await new Promise((resolve, reject) => { + const p = spawn(chromiumBinary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, HOME: home, DISPLAY: process.env.DISPLAY || ':1' }, + uid, gid + }) + let stderr = '' + p.stderr.on('data', d => { stderr += String(d) }) + p.on('error', reject) + p.on('close', code => code === 0 ? resolve() : reject(new Error(`${chromiumBinary} ${args.join(' ')} exited ${code}\n${stderr}`))) + }) const produced = `${extRoot}.crx` if (!fssync.existsSync(produced)) throw new Error('Chromium packer did not create .crx') await fs.copyFile(produced, outCrx) diff --git a/operator-api/test.js b/operator-api/test.js index b5f485da..42394e0c 100644 --- a/operator-api/test.js +++ b/operator-api/test.js @@ -515,15 +515,38 @@ async function suite_stream() { } async function suite_browser_ext() { await runTest('browser-ext', 'add unpacked extension from GitHub', async () => { - const r = await j('/browser/extension/add/unpacked', { + // Create form data for the extension upload + const form = new FormData() + form.set('github_url', 'https://github.com/raiden-staging/kernel-chrome-ext') + + const r = await raw('/browser/extension/add/unpacked', { method: 'POST', - body: JSON.stringify({ - github_url: 'https://github.com/SimGus/chrome-extension-v3-starter' - }) + body: form }) + + const body = await r.json() if (r.status !== 201) throw new Error(`bad status ${r.status}`) - if (!r.body.id || !r.body.version) throw new Error('missing extension details') - console.log(chalk.gray(`Extension ID: ${r.body.id}, Version: ${r.body.version}`)) + if (!body.id || !body.version) throw new Error('missing extension details') + console.log(chalk.gray(`Extension ID: ${body.id}, Version: ${body.version}`)) + }) + + await runTest('browser-ext', 'add unpacked extension from file', async () => { + // Create a test zip file + const testZipPath = tmpPath('test-extension.zip') + // This test would need a real zip file with extension content + // For now we'll just check if the endpoint handles file uploads correctly + + const form = new FormData() + form.set('archive_file', new Blob(['test content'], { type: 'application/zip' }), 'test-extension.zip') + + const r = await raw('/browser/extension/add/unpacked', { + method: 'POST', + body: form + }) + + // This will likely fail with 500 since we're not providing a real extension zip + // But we're testing the form handling, not the extension loading + if (![201, 500].includes(r.status)) throw new Error(`unexpected status ${r.status}`) }) } From 3e030fc4fff7775ff40612876420a7e71f4f8aa7 Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 18:48:02 +0100 Subject: [PATCH 68/70] Operator API [operator-api : remote chrome ext install fix attempt*] --- operator-api/src/routes/browser-ext.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-api/src/routes/browser-ext.js b/operator-api/src/routes/browser-ext.js index f488ebf6..e3e4a065 100644 --- a/operator-api/src/routes/browser-ext.js +++ b/operator-api/src/routes/browser-ext.js @@ -338,7 +338,7 @@ async function lookupUser(name) { } async function packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) { - const args = [`--pack-extension=${extRoot}`, `--pack-extension-key=${pemPath}`] + const args = [`--no-sandbox`, `--pack-extension=${extRoot}`, `--pack-extension-key=${pemPath}`] const { uid, gid, home } = await lookupUser(process.env.PACK_AS_USER || 'kernel') // ensure the packer can read the private key try { await fs.chown(pemPath, uid, gid) } catch { } From d24b97a4bd9a07630bd2bcd7e4fab4591f41285f Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 19:12:31 +0100 Subject: [PATCH 69/70] Operator API [operator-api : remote chrome ext works ; added auto pin to browser extension modules ; added audio capture to recording] --- operator-api/README.md | 185 +++++++++--------- operator-api/src/routes/browser-ext.js | 139 +++++++++++-- operator-api/src/services/recordingService.js | 69 ++++++- 3 files changed, 276 insertions(+), 117 deletions(-) diff --git a/operator-api/README.md b/operator-api/README.md index f5b095c4..ab5e752f 100644 --- a/operator-api/README.md +++ b/operator-api/README.md @@ -2,14 +2,15 @@ - New operator API to replace current Go server API, with added functionalities (supports previous endpoints & schema) - To use on PORT=10001 +- Packed with + deployment-test with `kernel-images`, on Docker & Unikraft --- # Todo ``` -- test ffmpeg capture with audio -- screen display resolution <> ffmpeg in case of resize +- test ffmpeg capture with audio (implemented, not tested) +- screen display resolution <> ffmpeg in case of resize (double check) - add more tools that are useful for agents - document api ``` @@ -66,19 +67,19 @@ bun build # binaries : dist/kernel-operator-api , dist/kernel-operator-test ## /browser-ext Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/browser/extension/add/unpacked | 〰️ | 〰️ | 〰️ | Supports GitHub URL or zip file upload +/browser/extension/add/unpacked | ✅ | ✅ | ✅ | Supports GitHub URL or zip file upload ## /bus Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/bus/publish | ✅ | ✅ | 〰️ | N/A -/bus/subscribe | ✅ | ✅ | 〰️ | N/A +/bus/publish | ✅ | ✅ | ✅ | N/A +/bus/subscribe | ✅ | ✅ | ✅ | N/A ## /clipboard Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/clipboard | ✅ | ✅ | 〰️ | N/A -/clipboard/stream | 〰️ | ❌ | 〰️ | timeout after 30000ms +/clipboard | ✅ | ✅ | ✅ | N/A +/clipboard/stream | 〰️ | ❌ | ❌ | timeout after 30000ms ## /computer Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes @@ -89,145 +90,145 @@ Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes ## /fs Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/fs/create_directory | ✅ | ✅ | 〰️ | N/A -/fs/delete_directory | ✅ | ✅ | 〰️ | N/A -/fs/delete_file | ✅ | ✅ | 〰️ | N/A -/fs/download | ✅ | ✅ | 〰️ | N/A -/fs/file_info | ✅ | ✅ | 〰️ | N/A -/fs/list_files | ✅ | ✅ | 〰️ | N/A -/fs/move | ✅ | ✅ | 〰️ | N/A -/fs/read_file | ✅ | ✅ | 〰️ | N/A -/fs/set_file_permissions | ✅ | ✅ | 〰️ | N/A -/fs/tail/stream | 〰️ | ✅ | 〰️ | N/A -/fs/upload | ✅ | ✅ | 〰️ | N/A -/fs/watch | 〰️ | ❌ | 〰️ | watch start failed -/fs/watch/{watch_id} | 〰️ | ❌ | 〰️ | watch start failed -/fs/watch/{watch_id}/events | 〰️ | ❌ | 〰️ | watch start failed -/fs/write_file | ✅ | ✅ | 〰️ | N/A +/fs/create_directory | ✅ | ✅ | ✅ | N/A +/fs/delete_directory | ✅ | ✅ | ✅ | N/A +/fs/delete_file | ✅ | ✅ | ✅ | N/A +/fs/download | ✅ | ✅ | ✅ | N/A +/fs/file_info | ✅ | ✅ | ✅ | N/A +/fs/list_files | ✅ | ✅ | ✅ | N/A +/fs/move | ✅ | ✅ | ✅ | N/A +/fs/read_file | ✅ | ✅ | ✅ | N/A +/fs/set_file_permissions | ✅ | ✅ | ✅ | N/A +/fs/tail/stream | 〰️ | ✅ | ✅ | N/A +/fs/upload | ✅ | ✅ | ✅ | N/A +/fs/watch | 〰️ | ❌ | ❌ | watch start failed +/fs/watch/{watch_id} | 〰️ | ❌ | ❌ | watch start failed +/fs/watch/{watch_id}/events | 〰️ | ❌ | ❌ | watch start failed +/fs/write_file | ✅ | ✅ | ✅ | N/A ## /health Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/health | ✅ | ✅ | 〰️ | N/A +/health | ✅ | ✅ | ✅ | N/A ## /input/desktop Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/input/combo/activate_and_type | ✅ | ✅ | 〰️ | N/A -/input/combo/activate_and_keys | ✅ | ✅ | 〰️ | N/A -/input/combo/window/center | ✅ | ✅ | 〰️ | N/A -/input/combo/window/snap | ✅ | ✅ | 〰️ | N/A -/input/desktop/count | ✅ | ✅ | 〰️ | N/A -/input/desktop/current | ✅ | ✅ | 〰️ | N/A -/input/desktop/viewport | ✅ | ✅ | 〰️ | N/A -/input/desktop/window_desktop | ✅ | ✅ | 〰️ | N/A -/input/display/geometry | ✅ | ✅ | 〰️ | N/A -/input/keyboard/key | ✅ | ✅ | 〰️ | N/A -/input/keyboard/key_down | ✅ | ✅ | 〰️ | N/A -/input/keyboard/key_up | ✅ | ✅ | 〰️ | N/A -/input/keyboard/type | ✅ | ✅ | 〰️ | N/A -/input/mouse/click | ✅ | ✅ | 〰️ | N/A -/input/mouse/down | ✅ | ✅ | 〰️ | N/A -/input/mouse/location | ✅ | ✅ | 〰️ | N/A -/input/mouse/move | ✅ | ✅ | 〰️ | N/A -/input/mouse/move_relative | ✅ | ✅ | 〰️ | N/A -/input/mouse/scroll | ✅ | ✅ | 〰️ | N/A -/input/mouse/up | ✅ | ✅ | 〰️ | N/A -/input/system/exec | ✅ | ✅ | 〰️ | N/A -/input/system/sleep | ✅ | ✅ | 〰️ | N/A -/input/window/activate | ✅ | ✅ | 〰️ | N/A -/input/window/active | ✅ | ✅ | 〰️ | N/A -/input/window/close | ✅ | ✅ | 〰️ | N/A -/input/window/focus | ✅ | ✅ | 〰️ | N/A -/input/window/focused | ✅ | ✅ | 〰️ | N/A -/input/window/geometry | ✅ | ✅ | 〰️ | N/A -/input/window/kill | ✅ | ✅ | 〰️ | N/A -/input/window/map | ✅ | ✅ | 〰️ | N/A -/input/window/minimize | ✅ | ✅ | 〰️ | N/A -/input/window/move_resize | ✅ | ✅ | 〰️ | N/A -/input/window/name | ✅ | ✅ | 〰️ | N/A -/input/window/pid | ✅ | ✅ | 〰️ | N/A -/input/window/raise | ✅ | ✅ | 〰️ | N/A -/input/window/unmap | ✅ | ✅ | 〰️ | N/A +/input/combo/activate_and_type | ✅ | ✅ | ✅ | N/A +/input/combo/activate_and_keys | ✅ | ✅ | ✅ | N/A +/input/combo/window/center | ✅ | ✅ | ✅ | N/A +/input/combo/window/snap | ✅ | ✅ | ✅ | N/A +/input/desktop/count | ✅ | ✅ | ✅ | N/A +/input/desktop/current | ✅ | ✅ | ✅ | N/A +/input/desktop/viewport | ✅ | ✅ | ✅ | N/A +/input/desktop/window_desktop | ✅ | ✅ | ✅ | N/A +/input/display/geometry | ✅ | ✅ | ✅ | N/A +/input/keyboard/key | ✅ | ✅ | ✅ | N/A +/input/keyboard/key_down | ✅ | ✅ | ✅ | N/A +/input/keyboard/key_up | ✅ | ✅ | ✅ | N/A +/input/keyboard/type | ✅ | ✅ | ✅ | N/A +/input/mouse/click | ✅ | ✅ | ✅ | N/A +/input/mouse/down | ✅ | ✅ | ✅ | N/A +/input/mouse/location | ✅ | ✅ | ✅ | N/A +/input/mouse/move | ✅ | ✅ | ✅ | N/A +/input/mouse/move_relative | ✅ | ✅ | ✅ | N/A +/input/mouse/scroll | ✅ | ✅ | ✅ | N/A +/input/mouse/up | ✅ | ✅ | ✅ | N/A +/input/system/exec | ✅ | ✅ | ✅ | N/A +/input/system/sleep | ✅ | ✅ | ✅ | N/A +/input/window/activate | ✅ | ✅ | ✅ | N/A +/input/window/active | ✅ | ✅ | ✅ | N/A +/input/window/close | ✅ | ✅ | ✅ | N/A +/input/window/focus | ✅ | ✅ | ✅ | N/A +/input/window/focused | ✅ | ✅ | ✅ | N/A +/input/window/geometry | ✅ | ✅ | ✅ | N/A +/input/window/kill | ✅ | ✅ | ✅ | N/A +/input/window/map | ✅ | ✅ | ✅ | N/A +/input/window/minimize | ✅ | ✅ | ✅ | N/A +/input/window/move_resize | ✅ | ✅ | ✅ | N/A +/input/window/name | ✅ | ✅ | ✅ | N/A +/input/window/pid | ✅ | ✅ | ✅ | N/A +/input/window/raise | ✅ | ✅ | ✅ | N/A +/input/window/unmap | ✅ | ✅ | ✅ | N/A ## /logs Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/logs/stream | ✅ | ✅ | 〰️ | N/A +/logs/stream | ✅ | ✅ | ✅ | N/A ## /macros Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/macros/create | ✅ | ✅ | 〰️ | N/A -/macros/list | ✅ | ✅ | 〰️ | N/A -/macros/run | ✅ | ✅ | 〰️ | N/A -/macros/{macro_id} | ✅ | ✅ | 〰️ | N/A +/macros/create | ✅ | ✅ | ✅ | N/A +/macros/list | ✅ | ✅ | ✅ | N/A +/macros/run | ✅ | ✅ | ✅ | N/A +/macros/{macro_id} | ✅ | ✅ | ✅ | N/A ## /metrics Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/metrics/snapshot | ✅ | ✅ | 〰️ | N/A -/metrics/stream | ✅ | ✅ | 〰️ | N/A +/metrics/snapshot | ✅ | ✅ | ✅ | N/A +/metrics/stream | ✅ | ✅ | ✅ | N/A ## /network/forward Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- /network/forward | 〰️ | 〰️ | 〰️ | N/A /network/forward/{forward_id} | 〰️ | 〰️ | 〰️ | N/A -/network/har/stream | 〰️ | ✅ | 〰️ | N/A -/network/intercept/rules | 〰️ | ✅ | 〰️ | N/A -/network/intercept/rules/{rule_set_id} | 〰️ | ✅ | 〰️ | N/A +/network/har/stream | 〰️ | ✅ | ✅ | N/A +/network/intercept/rules | 〰️ | ✅ | ✅ | N/A +/network/intercept/rules/{rule_set_id} | 〰️ | ✅ | ✅ | N/A /network/proxy/socks5/start | 〰️ | 〰️ | 〰️ | N/A /network/proxy/socks5/stop | 〰️ | 〰️ | 〰️ | N/A ## /os Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/os/locale | ✅ | ✅ | 〰️ | N/A +/os/locale | ✅ | ✅ | ✅ | N/A ## /pipe Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/pipe/recv/stream | ✅ | ✅ | 〰️ | N/A -/pipe/send | ✅ | ✅ | 〰️ | N/A +/pipe/recv/stream | ✅ | ✅ | ✅ | N/A +/pipe/send | ✅ | ✅ | ✅ | N/A ## /process Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/process/exec | ✅ | ✅ | 〰️ | N/A -/process/spawn | ✅ | ✅ | 〰️ | N/A -/process/{process_id}/kill | ✅ | ✅ | 〰️ | N/A -/process/{process_id}/status | ✅ | ✅ | 〰️ | N/A -/process/{process_id}/stdin | ✅ | ✅ | 〰️ | N/A -/process/{process_id}/stdout/stream | ✅ | ✅ | 〰️ | N/A +/process/exec | ✅ | ✅ | ✅ | N/A +/process/spawn | ✅ | ✅ | ✅ | N/A +/process/{process_id}/kill | ✅ | ✅ | ✅ | N/A +/process/{process_id}/status | ✅ | ✅ | ✅ | N/A +/process/{process_id}/stdin | ✅ | ✅ | ✅ | N/A +/process/{process_id}/stdout/stream | ✅ | ✅ | ✅ | N/A ## /recording Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/recording/delete | ✅ | ✅ | 〰️ | N/A -/recording/download | ✅ | ✅ | 〰️ | N/A -/recording/list | ✅ | ✅ | 〰️ | N/A -/recording/start | ✅ | ✅ | 〰️ | N/A -/recording/stop | ✅ | ✅ | 〰️ | N/A +/recording/delete | ✅ | ✅ | ✅ | N/A +/recording/download | ✅ | ✅ | ✅ | N/A +/recording/list | ✅ | ✅ | ✅ | N/A +/recording/start | ✅ | ✅ | ✅ | N/A +/recording/stop | ✅ | ✅ | ✅ | N/A ## /screenshot Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/screenshot/capture | ✅ | ✅ | 〰️ | N/A -/screenshot/{screenshot_id} | ✅ | ✅ | 〰️ | N/A +/screenshot/capture | ✅ | ✅ | ✅ | N/A +/screenshot/{screenshot_id} | ✅ | ✅ | ✅ | N/A ## /scripts Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/scripts/delete | 〰️ | ✅ | 〰️ | N/A -/scripts/list | 〰️ | ✅ | 〰️ | N/A -/scripts/run | 〰️ | ✅ | 〰️ | N/A +/scripts/delete | 〰️ | ✅ | ✅ | N/A +/scripts/list | 〰️ | ✅ | ✅ | N/A +/scripts/run | 〰️ | ✅ | ✅ | N/A /scripts/run/{run_id}/logs/stream | 〰️ | 〰️ | 〰️ | N/A -/scripts/upload | 〰️ | ✅ | 〰️ | N/A +/scripts/upload | 〰️ | ✅ | ✅ | N/A ## /stream Endpoint/service | API Build | Kernel:Docker | Kernel:Unikraft | Notes --- | --- | --- | --- | --- -/stream/start | ✅ | ✅ | 〰️ | N/A -/stream/stop | ✅ | ✅ | 〰️ | N/A -/stream/{stream_id}/metrics/stream | ✅ | ✅ | 〰️ | N/A +/stream/start | ✅ | ✅ | ✅ | N/A +/stream/stop | ✅ | ✅ | ✅ | N/A +/stream/{stream_id}/metrics/stream | ✅ | ✅ | ✅ | N/A diff --git a/operator-api/src/routes/browser-ext.js b/operator-api/src/routes/browser-ext.js index e3e4a065..95ce7f38 100644 --- a/operator-api/src/routes/browser-ext.js +++ b/operator-api/src/routes/browser-ext.js @@ -1,5 +1,12 @@ // src/routes/browser-ext.js -// ESM. Requires: hono, undici, extract-zip, ws +// ESM. Requires: hono, undici, extract-zip, ws, chalk +// Purpose: Force-install an unpacked Chromium extension from GitHub or a ZIP upload, +// publish it to a local "update server", and optionally pin its toolbar icon. +// Notes: +// - Only Manifest V3 is supported. +// - Pinning is supported via policy key: ExtensionSettings[].toolbar_pin = "force_pinned" (Chrome/Chromium 114+). +// - Incognito auto-enable is intentionally not implemented; Chrome policy does not support forcing it. + import { Hono } from 'hono' import fs from 'node:fs/promises' import fssync from 'node:fs' @@ -11,13 +18,23 @@ import { setTimeout as delay } from 'node:timers/promises' import extract from 'extract-zip' import { request } from 'undici' import { WebSocket } from 'ws' +import chalk from 'chalk' export const browserExtRouter = new Hono() -// POST /browser/extension/add/unpacked (multipart/form-data) +/* ────────────────────────────── HTTP routes ────────────────────────────── */ + +// POST /browser/extension/add/unpacked (multipart/form-data OR application/json) // fields: // github_url: string OR -// archive_file: File (.zip, manifest at root or in first-level dir) +// archive_file: File (.zip, manifest at root or one-level nested) +// Behavior: +// - Downloads/reads the archive +// - Extracts and validates manifest.json (MV3 only) +// - Packs extension with chromium --pack-extension using a stable per-source key +// - Publishes CRX + update.xml under repoStorageDir +// - Writes policy to force-install and (optionally) pin toolbar icon +// - Tries to hot-reload policy via DevTools; optionally restarts browser if needed browserExtRouter.post('/browser/extension/add/unpacked', async (c) => { const origin = new URL(c.req.url).origin const form = await readForm(c) @@ -28,24 +45,38 @@ browserExtRouter.post('/browser/extension/add/unpacked', async (c) => { chromiumBinary: process.env.CHROMIUM_BINARY || 'chromium', devtoolsHost: process.env.CHROME_HOST || '127.0.0.1', devtoolsPort: Number(process.env.CHROME_PORT || 9222), + + // Resolve a writable managed policy directory automatically policyDir: await detectPolicyDir([ '/etc/chromium/policies/managed', '/etc/opt/chromium/policies/managed', '/etc/opt/chrome/policies/managed', '/etc/chrome/policies/managed' ]), + + // Local "update server" root and base URL repoStorageDir: process.env.EXT_REPO_DIR || '/opt/extrepo', repoBaseUrl: process.env.EXT_REPO_BASE_URL || `${origin}/extrepo`, + + // Directory holding persistent RSA keys for stable extension IDs keyStoreDir: process.env.EXT_KEY_STORE_DIR || '/var/lib/chrome-ext-keys', + + // Toolbar pin control (Chrome/Chromium 114+). Safe to enable; ignored on older versions. + pinToToolbar: envBool(process.env.EXT_PIN_TOOLBAR, true), + tryHotReloadPolicy: true, fallbackRestart: true, - waitInstallTimeoutMs: 25000 + waitInstallTimeoutMs: 25_000 } + logInfo('incoming', { from: params.github_url ? 'github' : 'upload', pinToToolbar: params.pinToToolbar }) + try { const result = await addUnpackedExtension(params) + logOk('done', { id: result.id, version: result.version, pinned: result.pinned, installed: result.installed }) return c.json(result, 201) } catch (err) { + logFail('error', { error: err?.message || String(err) }) return c.json({ error: err.message || String(err) }, 500) } finally { if (form.cleanup) await form.cleanup().catch(() => { }) @@ -53,6 +84,9 @@ browserExtRouter.post('/browser/extension/add/unpacked', async (c) => { }) // GET /extrepo/* (serves CRX and update.xml from repoStorageDir) +// Example paths: +// /extrepo//.crx +// /extrepo//update.xml browserExtRouter.get('/extrepo/*', async (c) => { const baseDir = process.env.EXT_REPO_DIR || '/opt/extrepo' const tail = c.req.path.replace(/^\/extrepo\/?/, '') @@ -98,7 +132,9 @@ async function readForm(c) { archive_path = path.join(tmpRoot, 'upload.zip') const buf = Buffer.from(await file.arrayBuffer()) await fs.writeFile(archive_path, buf) - cleanup = async () => { try { await fs.rm(tmpRoot, { recursive: true, force: true }) } catch { } } + cleanup = async () => { + try { await fs.rm(tmpRoot, { recursive: true, force: true }) } catch { } + } } return { github_url, archive_path, cleanup } @@ -123,6 +159,10 @@ const DEFAULTS = { const NIBBLE_MAP = 'abcdefghijklmnop'.split('') +/** + * Add an unpacked extension by source (GitHub or uploaded ZIP), + * pack to CRX with a deterministic key, publish, and force-install via policy. + */ async function addUnpackedExtension({ github_url, archive_file_path, @@ -133,9 +173,10 @@ async function addUnpackedExtension({ repoStorageDir = DEFAULTS.repoStorageDir, repoBaseUrl = DEFAULTS.repoBaseUrl, keyStoreDir = DEFAULTS.keyStoreDir, + pinToToolbar = true, tryHotReloadPolicy = true, fallbackRestart = true, - waitInstallTimeoutMs = 25000 + waitInstallTimeoutMs = 25_000 } = {}) { assertOneSource(github_url, archive_file_path) @@ -147,62 +188,91 @@ async function addUnpackedExtension({ const srcZip = path.join(workRoot, 'src.zip') const unpackDir = path.join(workRoot, 'unpacked') + // Acquire source archive if (github_url) { + logStep('download', { github_url }) const zipUrl = await resolveGithubZipURL(github_url) await downloadToFile(zipUrl, srcZip) } else { + logStep('ingest-upload', { path: archive_file_path }) await fs.copyFile(archive_file_path, srcZip) } + // Unpack and locate manifest.json + logStep('extract', { to: unpackDir }) await extract(srcZip, { dir: unpackDir }) const extRoot = await resolveExtensionRoot(unpackDir) - const manifest = await readJson(path.join(extRoot, 'manifest.json')) + + // Validate manifest + const manifestPath = path.join(extRoot, 'manifest.json') + const manifest = await readJson(manifestPath) validateManifest(manifest) + logStep('manifest', { version: manifest.version, mv: manifest.manifest_version }) + // Stable key per source to keep extension ID constant across updates const sourceKeyId = await decideKeyId({ github_url, manifest }) const pemPath = path.join(keyStoreDir, `${sourceKeyId}.pem`) if (!fssync.existsSync(pemPath)) { + logStep('keygen', { id: sourceKeyId }) await ensureDir(keyStoreDir) const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) await fs.writeFile(pemPath, pem, { mode: 0o600 }) } + // Pack to CRX const outCrx = path.join(workRoot, 'packed.crx') + logStep('pack', { binary: chromiumBinary }) await packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) + // Compute extension ID from key const extId = await computeExtensionIdFromPem(pemPath) + logStep('ext-id', { id: extId }) + // Publish CRX + update.xml const publicDir = path.join(repoStorageDir, extId) await ensureDir(publicDir) const finalCrx = path.join(publicDir, `${extId}.crx`) await fs.copyFile(outCrx, finalCrx) const updateXmlPath = path.join(publicDir, 'update.xml') - const codebaseUrl = `${trimSlash(repoBaseUrl)}/${extId}/${extId}.crx` const updateUrl = `${trimSlash(repoBaseUrl)}/${extId}/update.xml` await writeUpdateXml({ updateXmlPath, extId, version: manifest.version, codebaseUrl }) + logStep('publish', { crx: finalCrx, update_xml: updateXmlPath, update_url: updateUrl }) + // Write policy: force-install and optionally force-pin const policyPath = path.join(policyDir, `force_${extId}.json`) - await fs.writeFile( - policyPath, - JSON.stringify({ ExtensionInstallForcelist: [`${extId};${updateUrl}`] }, null, 2) + '\n', - { mode: 0o644 } - ) + const policyPayload = { + ExtensionInstallForcelist: [`${extId};${updateUrl}`], + ...(pinToToolbar && { + ExtensionSettings: { + [extId]: { toolbar_pin: 'force_pinned' } // Chrome/Chromium 114+ + } + }) + } + await fs.writeFile(policyPath, JSON.stringify(policyPayload, null, 2) + '\n', { mode: 0o644 }) + logStep('policy-write', { path: policyPath, pin: !!pinToToolbar }) + // Detect profile dir and check prior installation const userDataDir = await locateUserDataDir(DEFAULTS.userDataDirsProbe) const extInstallDir = path.join(userDataDir, 'Default', 'Extensions', extId) const installedBefore = await dirExists(extInstallDir) + // Try to apply policy without restart if (tryHotReloadPolicy) { + logStep('policy-reload', { via: 'DevTools' }) await devtoolsReloadPolicies({ devtoolsHost, devtoolsPort }).catch(() => { }) } + // Wait for install on disk let installed = await waitForExtensionInstallOnDisk(extInstallDir, waitInstallTimeoutMs) + + // Fallback: restart browser to pick up policy if (!installed && fallbackRestart) { + logWarn('fallback-restart', { host: devtoolsHost, port: devtoolsPort }) await devtoolsRestartBrowser({ devtoolsHost, devtoolsPort }).catch(() => { }) - await waitDevToolsUp({ devtoolsHost, devtoolsPort, timeoutMs: 20000 }).catch(() => { }) - installed = await waitForExtensionInstallOnDisk(extInstallDir, 15000) + await waitDevToolsUp({ devtoolsHost, devtoolsPort, timeoutMs: 20_000 }).catch(() => { }) + installed = await waitForExtensionInstallOnDisk(extInstallDir, 15_000) } await safeRm(workRoot) @@ -215,7 +285,8 @@ async function addUnpackedExtension({ update_url: updateUrl, policy_path: policyPath, installed: installed || installedBefore || false, - profile_extensions_dir: extInstallDir + profile_extensions_dir: extInstallDir, + pinned: !!pinToToolbar } } @@ -244,7 +315,7 @@ async function detectPolicyDir(candidates) { return dir } catch { } } - // last resort still return first path; write will error explicitly if not writable + // Last resort: return first path; writes will error explicitly if not writable return candidates[0] } @@ -267,15 +338,17 @@ async function resolveGithubZipURL(input) { const parts = u.pathname.split('/').filter(Boolean) if (parts.length < 2) throw new Error('Invalid GitHub repo URL') + // Already a refs/heads archive URL if (parts.includes('archive') && parts.includes('refs') && parts.includes('heads')) { return String(u) } + // /owner/repo or /owner/repo/tree/ const treeIdx = parts.indexOf('tree') let branch = null if (treeIdx >= 0 && parts[treeIdx + 1]) branch = parts[treeIdx + 1] - const [owner, repo] = parts + const tries = [] if (branch) { tries.push(`https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${branch}`) @@ -293,6 +366,7 @@ async function resolveGithubZipURL(input) { async function resolveExtensionRoot(unpackedDir) { if (await fileExists(path.join(unpackedDir, 'manifest.json'))) return unpackedDir + const entries = await fs.readdir(unpackedDir, { withFileTypes: true }) const dirs = entries.filter((e) => e.isDirectory()) if (dirs.length === 1) { @@ -338,9 +412,9 @@ async function lookupUser(name) { } async function packWithChromium({ chromiumBinary, extRoot, pemPath, outCrx }) { + // chromium --pack-extension requires a readable key file; we chown to the target user if needed const args = [`--no-sandbox`, `--pack-extension=${extRoot}`, `--pack-extension-key=${pemPath}`] const { uid, gid, home } = await lookupUser(process.env.PACK_AS_USER || 'kernel') - // ensure the packer can read the private key try { await fs.chown(pemPath, uid, gid) } catch { } await new Promise((resolve, reject) => { const p = spawn(chromiumBinary, args, { @@ -500,3 +574,30 @@ async function waitForExtensionInstallOnDisk(extInstallDir, timeoutMs) { } return false } + +/* ─────────────────────────── logging helpers ─────────────────────────── */ + +function logStep(event, data) { log(chalk.cyan('[step]'), event, data) } +function logInfo(event, data) { log(chalk.blue('[info]'), event, data) } +function logWarn(event, data) { log(chalk.yellow('[warn]'), event, data) } +function logOk(event, data) { log(chalk.green('[ok]'), event, data) } +function logFail(event, data) { log(chalk.red('[fail]'), event, data) } + +function log(prefix, event, data) { + if (data && Object.keys(data).length) { + // Safe, one-line JSON for structured logs + console.log(prefix, event, JSON.stringify(data)) + } else { + console.log(prefix, event) + } +} + +/* ─────────────────────────── misc utilities ─────────────────────────── */ + +function envBool(value, def = false) { + if (value == null) return def + const v = String(value).trim().toLowerCase() + if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true + if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false + return def +} diff --git a/operator-api/src/services/recordingService.js b/operator-api/src/services/recordingService.js index 01bb0967..8d05c16e 100644 --- a/operator-api/src/services/recordingService.js +++ b/operator-api/src/services/recordingService.js @@ -1,6 +1,7 @@ import 'dotenv/config' import fs from 'node:fs' import path from 'node:path' +import { spawnSync } from 'node:child_process' import { execSpawn } from '../utils/exec.js' import { RECORDINGS_DIR } from '../utils/env.js' import { uid } from '../utils/ids.js' @@ -12,12 +13,68 @@ const DISPLAY = process.env.DISPLAY || ':0' const state = new Map() // id -> {proc, file, started_at, finished_at} -function buildArgsMp4({ framerate = 20, maxDurationInSeconds }) { - // We can omit video_size as ffmpeg will detect the screen dimensions automatically - const input = ['-f', 'x11grab', '-i', `${DISPLAY}.0`] - const common = ['-r', String(framerate), '-vcodec', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'] - if (maxDurationInSeconds) common.push('-t', String(maxDurationInSeconds)) - return [...input, ...common] +// Best-effort discovery of a PulseAudio monitor for "what-you-hear" capture. +let _cachedPulseMonitor = undefined +function detectPulseMonitor() { + if (_cachedPulseMonitor !== undefined) return _cachedPulseMonitor + const env = { ...process.env } + try { + // 1) Try the real default sink -> ".monitor" + let sink = null + const getSink = spawnSync('pactl', ['get-default-sink'], { env, encoding: 'utf8' }) + if (getSink.status === 0) sink = getSink.stdout.trim() + if (!sink) { + // Older PulseAudio: parse "pactl info" + const info = spawnSync('pactl', ['info'], { env, encoding: 'utf8' }) + if (info.status === 0) { + const m = info.stdout.match(/Default Sink:\s*(.+)\s*$/m) + if (m) sink = m[1].trim() + } + } + const list = spawnSync('pactl', ['list', 'short', 'sources'], { env, encoding: 'utf8' }) + const sourcesTxt = list.status === 0 ? list.stdout : '' + const hasSource = (name) => sourcesTxt.split('\n').some((ln) => ln.split('\t')[1] === name) + if (sink && hasSource(`${sink}.monitor`)) { + _cachedPulseMonitor = `${sink}.monitor` + return _cachedPulseMonitor + } + // 2) Project's common virtual sink name + if (hasSource('audio_output.monitor')) { + _cachedPulseMonitor = 'audio_output.monitor' + return _cachedPulseMonitor + } + } catch (_) { + // ignore + } + _cachedPulseMonitor = null + return null +} + +function buildArgsMp4({ framerate = 20, maxDurationInSeconds, audio = true }) { + const args = ['-nostdin', '-hide_banner'] + // Video input (x11grab) + args.push('-f', 'x11grab') + // Width/height omitted intentionally; X server provides exact geometry + args.push('-i', `${DISPLAY}.0`) + // Optional audio input (PulseAudio "monitor" of output) + const pulseMonitor = audio ? detectPulseMonitor() : null + if (pulseMonitor) { + args.push('-f', 'pulse', '-i', pulseMonitor) + } + // Output encoders and common options + args.push( + '-r', String(framerate), + '-c:v', 'libx264', + '-preset', 'veryfast', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart' + ) + if (maxDurationInSeconds) args.push('-t', String(maxDurationInSeconds)) + if (pulseMonitor) { + // AAC in MP4; 48k stereo; stop when the shorter stream ends + args.push('-c:a', 'aac', '-b:a', '128k', '-ac', '2', '-ar', '48000', '-shortest') + } + return args } export function startRecording({ id, framerate, maxDurationInSeconds, maxFileSizeInMB }) { From b92ca5e11775ae9113dec84c3f73e59aed4ac431 Mon Sep 17 00:00:00 2001 From: raiden-staging Date: Mon, 11 Aug 2025 19:38:10 +0100 Subject: [PATCH 70/70] =?UTF-8?q?Added=20Operator=20API=20(v1)=20?= =?UTF-8?q?=F0=9F=8E=89=20|=20Packaged=20with=20kernel-images=20?= =?UTF-8?q?=F0=9F=93=A6=20|=20Tested=20deployment=20with=20Unikraft=20?= =?UTF-8?q?=E2=9C=85=20|=20Added=20installing=20Chromium=20extensions=20re?= =?UTF-8?q?motely=20[operator=20api:=20/browser/extension/add/unpacked]=20?= =?UTF-8?q?=F0=9F=93=A1=20|=20Kernel-themed=20loading=20animation=20on=20w?= =?UTF-8?q?eb=20client=20=E2=9C=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- images/chromium-headful/wrapper.sh | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 5695f5d0..bd52458b 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -331,6 +331,62 @@ elif [[ "${WITH_KERNEL_OPERATOR_API:-}" == "true" ]]; then # Run the operator API with the parsed environment variables grep -v "^#" /tmp/kernel-operator/.kernel-operator.env | xargs -I{} /usr/local/bin/kernel-operator-api {} & pid4=$! + # close the "--no-sandbox unsupported flag" warning when running as root + # in the unikernel runtime we haven't been able to get chromium to launch as non-root + # without cryptic crashpad errors + # and when running as root you must use the --no-sandbox flag, + # which generates a warning + if [[ "${RUN_AS_ROOT:-}" == "true" ]]; then + echo "Running as root, attempting to dismiss the --no-sandbox unsupported flag warning" + if read -r WIDTH HEIGHT <<< "$(xdotool getdisplaygeometry 2>/dev/null)"; then + # Work out an x-coordinate slightly inside the right-hand edge of the + OFFSET_X=$(( WIDTH - 30 )) + if (( OFFSET_X < 0 )); then + OFFSET_X=0 + fi + + # Wait for kernel-images API port 10001 to be ready. + echo "Waiting for kernel-images API port 127.0.0.1:10001..." + while ! nc -z 127.0.0.1 10001 2>/dev/null; do + sleep 0.5 + done + echo "Port 10001 is open" + + # Wait for Chromium window to open before dismissing the --no-sandbox warning. + target='New Tab - Chromium' + echo "Waiting for Chromium window \"${target}\" to appear and become active..." + while :; do + win_id=$(xwininfo -root -tree 2>/dev/null | awk -v t="$target" '$0 ~ t {print $1; exit}') + if [[ -n $win_id ]]; then + win_id=${win_id%:} + if xdotool windowactivate --sync "$win_id"; then + echo "Focused window $win_id ($target) on $DISPLAY" + break + fi + fi + sleep 0.5 + done + + # wait... not sure but this just increases the likelihood of success + # without the sleep you often open the live view and see the mouse hovering over the "X" to dismiss the warning, suggesting that it clicked before the warning or chromium appeared + sleep 5 + + # Attempt to click the warning's close button + echo "Clicking the warning's close button at x=$OFFSET_X y=115" + if curl -s -o /dev/null -X POST \ + http://localhost:10001/computer/click_mouse \ + -H "Content-Type: application/json" \ + -d "{\"x\":${OFFSET_X},\"y\":115}"; then + echo "Successfully clicked the warning's close button" + else + echo "Failed to click the warning's close button" >&2 + fi + else + echo "xdotool failed to obtain display geometry; skipping sandbox warning dismissal." >&2 + fi + fi + + if [[ "${DEBUG_OPERATOR_TEST:-}" == "true" ]]; then echo "[kernel-operator:test] sleep 10 then Running tests once" sleep 10