Initial commit

This commit is contained in:
2026-07-23 09:12:00 +09:00
commit 521d11ffa6
41 changed files with 8799 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.git
.agents
.codex
.env
bun.lockb
npm-debug.log
Dockerfile
docker-compose.yml
.dockerignore
+14
View File
@@ -0,0 +1,14 @@
HOST=0.0.0.0
PORT=3100
REMOTE_DISPLAY=:2
REMOTE_DISPLAY_SCREEN=1600x900x24
PLATFORM_WORKSPACE_ROOT=/home/ubuntu/.caches
CAMERA_VIDEO_DEVICE=/dev/video0
CAMERA_SIGNALING_URL=ws://ec2-13-125-135-255.ap-northeast-2.compute.amazonaws.com:3000
CAMERA_ROOM_ID=jetson-1
KASMVNC_DISPLAY=:1
KASMVNC_WEB_PORT=6901
KASMVNC_PROTOCOL=http
KASMVNC_INTERFACE=0.0.0.0
KASMVNC_GEOMETRY=1600x900
KASMVNC_DESKTOP=XFCE
+15
View File
@@ -0,0 +1,15 @@
HOST=0.0.0.0
PORT=3100
PLATFORM_PROVISION_URL=https://portal-api.autonomystudio.net/api/provision
REMOTE_DISPLAY=:2
REMOTE_DISPLAY_SCREEN=1600x900x24
PLATFORM_WORKSPACE_ROOT=/home/ubuntu/.caches
CAMERA_VIDEO_DEVICE=/dev/video0
CAMERA_SIGNALING_URL=ws://127.0.0.1:3000
CAMERA_ROOM_ID=jetson-1
KASMVNC_DISPLAY=:1
KASMVNC_WEB_PORT=6901
KASMVNC_PROTOCOL=http
KASMVNC_INTERFACE=0.0.0.0
KASMVNC_GEOMETRY=1600x900
KASMVNC_DESKTOP=XFCE
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.codex
.agents
+41
View File
@@ -0,0 +1,41 @@
FROM oven/bun:1.2.23-debian
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3100
ENV AGENT_CONFIG=/app/agent.config.json
ENV WORKSPACE_ROOT=/workspace
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
wget \
sudo \
python3 \
python3-pip \
python3-gi \
gir1.2-gstreamer-1.0 \
gir1.2-gst-plugins-base-1.0 \
gstreamer1.0-tools \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
v4l-utils \
&& python3 -m pip install --break-system-packages --no-cache-dir websockets \
&& rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
RUN bun install --production --frozen-lockfile
COPY agent.config.json ./
COPY src ./src
COPY public ./public
COPY scripts ./scripts
COPY ros_bridge ./ros_bridge
EXPOSE 3100
CMD ["bun", "run", "start"]
+231
View File
@@ -0,0 +1,231 @@
# Platform Agent Runbook
This runbook covers running the Platform Agent on a remote device and connecting a KasmVNC-based remote desktop.
## Role
The agent provides these capabilities on the remote platform:
- Start, stop, and inspect a KasmVNC XFCE remote desktop
- Browse, read, and save workspace files
- List, start, stop, log, and connect terminals to Docker modules
- Start and stop the camera WebRTC sender
- List `/dev/video*` camera devices
## Recommended Runtime
Host execution is currently recommended over Docker.
Reasons:
- KasmVNC/XFCE is simpler when attached to the host desktop environment
- Camera and GStreamer stacks are commonly validated on the host
- Adding GStreamer and desktop packages to the Docker image increases image size
## Required Host Environment
```text
bun
KasmVNC
XFCE
python3
python3-gi
websockets
GStreamer webrtcbin
v4l-utils
Docker
```
Check commands:
```bash
bun --version
command -v vncserver || command -v kasmvncserver
python3 -c "import websockets, gi"
gst-inspect-1.0 webrtcbin
v4l2-ctl --list-devices
docker ps
```
## Install
```bash
cd /home/chyoo/tmp/agent_v2
bun install
```
`bun install` installs only JavaScript dependencies. System packages such as KasmVNC, XFCE, and GStreamer must be installed separately.
## Run
```bash
cd /home/chyoo/tmp/agent_v2
HOST=0.0.0.0 PORT=3100 bun run start
```
Development run:
```bash
cd /home/chyoo/tmp/agent_v2
HOST=0.0.0.0 PORT=3100 bun run dev
```
Use this URL from the Electron app or browser:
```text
http://<platform-ip>:3100
```
## Main Environment Values
```bash
HOST=0.0.0.0
PORT=3100
WORKSPACE_ROOT=/home/ubuntu/.caches
KASMVNC_DISPLAY=:1
KASMVNC_WEB_PORT=6901
KASMVNC_PROTOCOL=http
KASMVNC_INTERFACE=0.0.0.0
KASMVNC_GEOMETRY=1600x900
KASMVNC_DESKTOP=XFCE
CAMERA_ENABLED=true
CAMERA_SIGNALING_URL=ws://SIGNALING_HOST:3000
CAMERA_ROOM_ID=jetson-1
CAMERA_VIDEO_DEVICE=/dev/video0
```
## KasmVNC API
```bash
curl http://127.0.0.1:3100/api/kasmvnc/status
curl -X POST http://127.0.0.1:3100/api/kasmvnc/start
curl -X POST http://127.0.0.1:3100/api/kasmvnc/stop
```
Default command run by the agent:
```bash
vncserver :1 -select-de XFCE -geometry 1600x900 -depth 24 -websocketPort 6901 -interface 0.0.0.0
```
Default KasmVNC web URL:
```text
http://<platform-ip>:6901
```
## API Checks
Health and config:
```bash
curl http://127.0.0.1:3100/health
curl http://127.0.0.1:3100/api/config
```
Workspace:
```bash
curl http://127.0.0.1:3100/api/workspace/summary
curl http://127.0.0.1:3100/api/workspace/tree
```
Camera:
```bash
curl http://127.0.0.1:3100/api/camera/devices
curl http://127.0.0.1:3100/api/camera/status
curl -X POST http://127.0.0.1:3100/api/camera/start
curl -X POST http://127.0.0.1:3100/api/camera/stop
```
## systemd Example
```ini
[Unit]
Description=Platform Agent
After=network-online.target
[Service]
WorkingDirectory=/home/ubuntu/tmp/agent_v2
ExecStart=/home/ubuntu/.bun/bin/bun run start
Restart=always
Environment=HOST=0.0.0.0
Environment=PORT=3100
Environment=WORKSPACE_ROOT=/home/ubuntu/.caches
Environment=KASMVNC_DISPLAY=:1
Environment=KASMVNC_WEB_PORT=6901
Environment=KASMVNC_PROTOCOL=http
Environment=KASMVNC_INTERFACE=0.0.0.0
Environment=KASMVNC_GEOMETRY=1600x900
Environment=KASMVNC_DESKTOP=XFCE
Environment=CAMERA_ENABLED=true
Environment=CAMERA_SIGNALING_URL=ws://SIGNALING_HOST:3000
Environment=CAMERA_ROOM_ID=jetson-1
Environment=CAMERA_VIDEO_DEVICE=/dev/video0
[Install]
WantedBy=multi-user.target
```
Register the service:
```bash
sudo cp platform-agent.service /etc/systemd/system/platform-agent.service
sudo systemctl daemon-reload
sudo systemctl enable platform-agent
sudo systemctl start platform-agent
sudo systemctl status platform-agent
```
Logs:
```bash
journalctl -u platform-agent -f
```
## Docker Run
Docker compose is available but optional.
```bash
cd /home/chyoo/tmp/agent_v2
cp .env.example .env
docker compose up -d --build
```
Notes:
- Running KasmVNC/XFCE inside a container requires separate desktop session setup
- If KasmVNC and camera integration are already validated on the host, host execution is usually lighter
## Troubleshooting
Check ports:
```bash
ss -ltnp | grep -E '3100|6901'
```
Check KasmVNC:
```bash
command -v vncserver || command -v kasmvncserver
vncserver -list
```
Check camera devices:
```bash
ls -l /dev/video*
v4l2-ctl --list-devices
```
Check GStreamer:
```bash
gst-inspect-1.0 webrtcbin
gst-inspect-1.0 nvv4l2h264enc
```
+254
View File
@@ -0,0 +1,254 @@
# Platform Agent
An integrated agent for Jetson and Mobility PC devices that connects Platform IDE to local desktop, workspace, camera, Docker modules, and ROS bridge services.
This project combines the previous `platform-agent`, `autoware-back`, and `nav2-back` roles into one process and one configuration file.
## Features
- KasmVNC remote desktop status, start, and stop
- Workspace file browsing, reading, saving, and image preview
- Docker-based Autoware/Nav2 module listing, logs, start, and stop
- Browser terminal WebSocket
- Camera device listing, MJPEG, and WebRTC sender control
- Unified Autoware/Nav2 ROS bridge control API
- Web settings UI
## Run
```bash
bun install
bun run start
```
Default URL:
```text
http://127.0.0.1:3100
```
Settings UI:
```text
http://<device-ip>:3100
```
KasmVNC iframe:
```text
http://<device-ip>:3100/remote
```
## Install
curl -fsSL https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh | sh
Install from a local checkout:
```bash
cd /path/to/agent_v2
sh scripts/install.sh
```
For remote `curl | sh` installs, publish an architecture-specific installer archive and a small bootstrap script. The TUI binary must be built for both `amd64` and `arm64`.
```bash
curl -fsSL https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh | sh
```
The default install path is `/opt/platform-agent`.
```bash
PLATFORM_AGENT_DIR=/opt/platform-agent PLATFORM_AGENT_USER=ubuntu sh scripts/install.sh
```
Tailscale settings can be provisioned from your backend instead of asking the installer user to enter auth keys.
```bash
sh scripts/install.sh
```
The default provisioning endpoint is `https://portal-api.autonomystudio.net/api/provision`. Override it with `PLATFORM_PROVISION_URL` when the backend runs somewhere else.
```bash
PLATFORM_PROVISION_URL=https://your-domain/api/provision sh scripts/install.sh
```
In Launcher, open **Platform management** and select **Register** to create a one-time six-digit registration code. Enter the Autonomy Studio account email and that code in the installer. The code expires after five minutes, can be used only once, and is not written to the final install configuration file.
```json
{
"hostname": "device-hostname",
"os": "linux",
"arch": "arm64",
"kernel": "5.15.0-tegra",
"distro": "Ubuntu 22.04.5 LTS",
"device": {
"hostname": "device-hostname",
"os": "linux",
"arch": "arm64",
"kernel": "5.15.0-tegra",
"distro": "Ubuntu 22.04.5 LTS"
}
}
```
The backend response supports these keys. Both camelCase and snake_case are accepted.
```json
{
"installTailscale": true,
"tailscaleAuthKey": "tskey-auth-test-autonomy-studio",
"tailscaleLoginServer": "https://headscale.example.com",
"tailscaleHostname": "device-hostname",
"tailscaleExtraArgs": "--accept-routes"
}
```
If `tailscaleAuthKey` is present, `installTailscale` is treated as `true`.
Interactive installs prefer the Bubble Tea TUI. If Go is available on the device, the installer can run the source below with `go run`; otherwise it falls back to shell prompts.
```text
scripts/installer-tui/
```
When shipping prebuilt TUI binaries in a release:
```bash
PLATFORM_INSTALLER_TUI_URL=https://your-domain/platform-agent-installer-tui-linux-{arch} \
sh scripts/install.sh
```
Example TUI binary builds:
```bash
cd scripts/installer-tui
go mod tidy
GOOS=linux GOARCH=amd64 go build -o installer-tui-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o installer-tui-linux-arm64 .
```
For a noninteractive environment-only install:
```bash
PLATFORM_AGENT_INTERACTIVE=false \
PLATFORM_AGENT_DIR=/opt/platform-agent \
WORKSPACE_ROOT=/home/ubuntu/.caches \
ROS_DOMAIN_ID=0 \
sh scripts/install.sh
```
If you need to force Tailscale settings in a noninteractive install, the existing environment variables still work.
```bash
PLATFORM_AGENT_INTERACTIVE=false \
INSTALL_TAILSCALE=true \
TAILSCALE_AUTH_KEY=tskey-auth-... \
sh scripts/install.sh
```
Install steps are printed with progress output.
Autoware/Nav2 is not selected during install. The unified bridge supports both, and the active target can be changed after install in the Settings UI `ROS Bridge` section or by choosing which ROS bridge module to run. Use `ROBOT_STACK=autoware|nav2` only when you need to force the initial value in a noninteractive install.
## Unified Config
The default config file is `agent.config.json`.
Main sections:
- `server`: agent host, port, and public URL
- `workspace`: workspace root exposed to Platform IDE
- `desktop`: KasmVNC display, web port, and resolution
- `camera`: camera device, signaling URL, and resolution
- `ros`: Autoware/Nav2 stack, ROS_DOMAIN_ID, and bridge URL
- `modules`: module run script paths
Use `AGENT_CONFIG` to override the config file path.
## Platform IDE Compatible API
```text
GET /health
GET /api/config
PUT /api/config
GET /api/workspace/summary
GET /api/workspace/tree
GET /api/workspace/file
GET /api/workspace/image
POST /api/workspace/file
GET /api/modules/workspaces
GET /api/modules/docker-statuses
GET /api/modules/logs
POST /api/modules/start
POST /api/modules/stop
WS /api/modules/terminal
GET /api/camera/status
GET /api/camera/devices
POST /api/camera/start
POST /api/camera/stop
GET /api/camera/mjpeg
GET /api/kasmvnc/status
POST /api/kasmvnc/start
POST /api/kasmvnc/stop
GET /api/bridge/status
GET /api/bridge/stack
POST /api/bridge/clear
POST /api/bridge/stop
POST /api/bridge/reset
```
Autoware/Nav2 viewer compatible API:
```text
POST /plan
POST /drive
POST /clear
POST /stop
POST /reset
```
## ROS Bridge
The unified bridge is `ros_bridge/bridge.py`. It uses `ROBOT_STACK=autoware|nav2` or `agent.config.json` `ros.stack` to choose the active target behavior.
Direct run example:
```bash
source /opt/ros/humble/setup.bash
pip install -r ros_bridge/requirements.txt
ROBOT_STACK=autoware ROS_DOMAIN_ID=0 python3 ros_bridge/bridge.py
```
Docker image build examples:
```bash
docker build -f ros_bridge/Dockerfile.autoware -t autoware-ros-bridge ros_bridge
docker build -f ros_bridge/Dockerfile.nav2 -t nav2-ros-bridge ros_bridge
```
## Host Dependencies
Install the minimum agent first, then add optional host dependencies for the features used on each device.
- Common: Bun, Docker
- Remote desktop: KasmVNC, XFCE
- Camera: GStreamer, v4l-utils, python3-gi, websockets
- ROS bridge: ROS2 Humble and message packages for the active stack
- NVIDIA GPU containers: nvidia-container-toolkit or device-specific GPU runtime
## Verify
```bash
bun run typecheck
curl http://127.0.0.1:3100/health
curl http://127.0.0.1:3100/api/config
curl http://127.0.0.1:3100/api/kasmvnc/status
curl http://127.0.0.1:3100/api/bridge/status
```
+102
View File
@@ -0,0 +1,102 @@
{
"server": {
"host": "0.0.0.0",
"port": 3100,
"publicBaseUrl": "http://127.0.0.1:3100"
},
"security": {
"gatewayToken": ""
},
"iframe": {
"title": "KasmVNC",
"allowOrigins": [
"file:",
"app://platform-ide",
"http://localhost:*",
"http://127.0.0.1:*"
]
},
"desktop": {
"provider": "kasmvnc",
"display": ":1",
"webPort": 6901,
"protocol": "http",
"interface": "0.0.0.0",
"geometry": "1600x900",
"desktop": "XFCE"
},
"camera": {
"enabled": false,
"autoStart": true,
"streamName": "platform-camera",
"signalingUrl": "ws://127.0.0.1:3000",
"roomId": "jetson-1",
"videoDevice": "/dev/video0",
"width": 1920,
"height": 1080,
"framerate": 30,
"bitrate": 4000000,
"senderCommand": "python3",
"senderArgs": [
"scripts/jetson_webrtc_sender.py"
],
"pipelineDesc": ""
},
"workspace": {
"root": "~/.caches",
"maxFileSize": 10485760,
"editableExtensions": [
".bash",
".c",
".cc",
".cfg",
".cmake",
".conf",
".cpp",
".css",
".csv",
".h",
".hpp",
".html",
".ini",
".js",
".json",
".jsx",
".launch",
".log",
".lua",
".md",
".msg",
".py",
".rs",
".rviz",
".sh",
".srv",
".toml",
".ts",
".tsx",
".txt",
".urdf",
".xml",
".yaml",
".yml"
]
},
"ros": {
"stack": "autoware",
"domainId": "0",
"bridgeHost": "127.0.0.1",
"bridgePort": 8765,
"bridgeUrl": "http://127.0.0.1:8765",
"autowareImage": "autoware-ros-bridge",
"nav2Image": "nav2-ros-bridge",
"routeInput": "rviz_topic",
"routePrefix": "/api/routing",
"mapPointStride": 8,
"mapPointLimit": 80000
},
"modules": {
"autowareRunScript": "docker/docker_run_autoware-universe:humble-20240301-cuda.sh",
"nav2RunScript": "docker/docker_run_future-nav2:humble.sh"
}
}
+176
View File
@@ -0,0 +1,176 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "platform-agent",
"dependencies": {
"@tomoxv/gstwebrtc-api": "^2.0.0",
"hono": "^4.10.7",
},
"devDependencies": {
"@types/bun": "^1.3.2",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@tomoxv/gstwebrtc-api": ["@tomoxv/gstwebrtc-api@2.0.0", "", { "dependencies": { "patch-package": "8.0.0", "webrtc-adapter": "8.2.3" } }, "sha512-JWjKrwVyGWc1HpXGAOo6Nz7HpQOMSRragn2dJeq8oQKOJcZ6Vaq8KPmZTC9aFoouzVpFvlAdzrAhpAif7IGSug=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
"fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
"patch-package": ["patch-package@8.0.0", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^9.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.0.33", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="],
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"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=="],
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"webrtc-adapter": ["webrtc-adapter@8.2.3", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
}
}
+28
View File
@@ -0,0 +1,28 @@
services:
platform-agent:
build:
context: .
dockerfile: Dockerfile
image: platform-agent:latest
container_name: platform-agent
restart: unless-stopped
network_mode: host
privileged: true
environment:
HOST: 0.0.0.0
PORT: 3100
AGENT_CONFIG: /app/agent.config.json
WORKSPACE_ROOT: /workspace
ROS_BRIDGE_URL: ${ROS_BRIDGE_URL:-http://127.0.0.1:8765}
ROS_DOMAIN_ID: ${ROS_DOMAIN_ID:-0}
ROBOT_STACK: ${ROBOT_STACK:-autoware}
CAMERA_ENABLED: ${CAMERA_ENABLED:-false}
CAMERA_SIGNALING_URL: ${CAMERA_SIGNALING_URL:-ws://127.0.0.1:3000}
CAMERA_ROOM_ID: ${CAMERA_ROOM_ID:-jetson-1}
CAMERA_VIDEO_DEVICE: ${CAMERA_VIDEO_DEVICE:-/dev/video0}
volumes:
- ${PLATFORM_WORKSPACE_ROOT:-/home/ubuntu/.caches}:/workspace:rw
- /dev:/dev
- /tmp:/tmp
devices:
- ${CAMERA_VIDEO_DEVICE:-/dev/video0}:${CAMERA_VIDEO_DEVICE:-/dev/video0}
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env sh
set -eu
# Public base URL containing platform-agent-linux-amd64.tar.gz and
# platform-agent-linux-arm64.tar.gz. Keep this bootstrap free of secrets.
RELEASE_BASE_URL="${PLATFORM_AGENT_RELEASE_BASE_URL:-https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent}"
case "$(uname -m 2>/dev/null || true)" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*)
printf 'Unsupported CPU architecture: %s\n' "$(uname -m 2>/dev/null || printf unknown)" >&2
exit 1
;;
esac
if command -v curl >/dev/null 2>&1; then
download() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
download() { wget -qO "$2" "$1"; }
else
printf '%s\n' 'curl or wget is required to install Platform Agent.' >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT INT TERM
ARCHIVE_URL="${RELEASE_BASE_URL%/}/platform-agent-linux-${ARCH}.tar.gz"
ARCHIVE_PATH="$TMP_DIR/platform-agent.tar.gz"
printf 'Downloading Platform Agent for linux-%s...\n' "$ARCH"
download "$ARCHIVE_URL" "$ARCHIVE_PATH"
tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR"
PACKAGE_DIR="$TMP_DIR/platform-agent"
if [ ! -x "$PACKAGE_DIR/scripts/install.sh" ]; then
PACKAGE_DIR="$(find "$TMP_DIR" -type f -path '*/scripts/install.sh' -print -quit | sed 's#/scripts/install.sh##')"
fi
if [ -z "${PACKAGE_DIR:-}" ] || [ ! -x "$PACKAGE_DIR/scripts/install.sh" ]; then
printf '%s\n' 'Downloaded archive does not contain scripts/install.sh.' >&2
exit 1
fi
exec "$PACKAGE_DIR/scripts/install.sh"
+20
View File
@@ -0,0 +1,20 @@
{
"name": "platform-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/server.tsx",
"start": "bun src/server.tsx",
"build": "bun build src/server.tsx --outdir dist --target bun",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tomoxv/gstwebrtc-api": "^2.0.0",
"hono": "^4.10.7"
},
"devDependencies": {
"@types/bun": "^1.3.2",
"typescript": "^5.9.3"
}
}
+107
View File
@@ -0,0 +1,107 @@
const form = document.querySelector("[data-config-form]");
const statusGrid = document.querySelector("[data-status-grid]");
const message = document.querySelector("[data-message]");
const refreshButton = document.querySelector("[data-refresh]");
function getPath(object, path) {
return path.split(".").reduce((current, key) => current?.[key], object);
}
function setPath(object, path, value) {
const parts = path.split(".");
let cursor = object;
for (const part of parts.slice(0, -1)) {
cursor[part] ??= {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = value;
}
function coerceInputValue(input) {
if (input.type === "checkbox") return input.checked;
if (input.type === "number") return Number(input.value);
return input.value.trim();
}
function renderStatus(config, bridge, kasm, camera) {
const items = [
["Agent", config?.server?.publicBaseUrl ?? "online"],
["Workspace", config?.workspace?.root ?? "-"],
["ROS", `${config?.ros?.stack ?? "-"} · ${bridge?.stack?.stack ?? bridge?.health?.stack ?? "offline"}`],
["KasmVNC", kasm?.running ? `running · ${kasm.url}` : kasm?.installed ? "stopped" : "not installed"],
["Camera", camera?.enabled ? `${camera.videoDevice} · enabled` : "disabled"],
];
statusGrid.innerHTML = items.map(([label, value]) => `
<article>
<span>${label}</span>
<strong>${value}</strong>
</article>
`).join("");
}
function fillForm(config) {
for (const input of form.elements) {
if (!input.name) continue;
const value = getPath(config, input.name);
if (input.type === "checkbox") {
input.checked = Boolean(value);
} else if (value !== undefined) {
input.value = String(value);
}
}
}
function readFormPatch() {
const patch = {};
for (const input of form.elements) {
if (!input.name) continue;
setPath(patch, input.name, coerceInputValue(input));
}
return patch;
}
async function json(path, init) {
const response = await fetch(path, init);
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.detail || `HTTP ${response.status}`);
return data;
}
async function refresh() {
message.textContent = "Loading...";
const [config, bridge, kasm, camera] = await Promise.all([
json("/api/config"),
json("/api/bridge/status").catch((error) => ({ ok: false, health: { error: error.message } })),
json("/api/kasmvnc/status").catch((error) => ({ ok: false, message: error.message })),
json("/api/camera/status").catch((error) => ({ enabled: false, error: error.message })),
]);
fillForm(config);
renderStatus(config, bridge, kasm, camera);
message.textContent = "";
}
form?.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "Saving...";
try {
const config = await json("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(readFormPatch()),
});
fillForm(config);
await refresh();
message.textContent = "Saved.";
} catch (error) {
message.textContent = error instanceof Error ? error.message : "Save failed.";
}
});
refreshButton?.addEventListener("click", () => refresh().catch((error) => {
message.textContent = error instanceof Error ? error.message : "Refresh failed.";
}));
refresh().catch((error) => {
message.textContent = error instanceof Error ? error.message : "Load failed.";
});
+190
View File
@@ -0,0 +1,190 @@
const state = document.querySelector("[data-state]");
const video = document.querySelector("[data-video]");
const image = document.querySelector("[data-mjpeg]");
const logs = document.querySelector("[data-logs]");
const modeSelect = document.querySelector("[data-mode]");
const startButton = document.querySelector("[data-start]");
const stopButton = document.querySelector("[data-stop]");
let ws = null;
let pc = null;
let statsTimer = null;
function setState(message) {
if (state) state.textContent = message;
}
function renderLogs(items = []) {
if (!logs) return;
logs.textContent = items.slice(-30).join("\n");
logs.scrollTop = logs.scrollHeight;
}
async function getStatus() {
const response = await fetch("/api/camera/status");
const status = await response.json();
renderLogs(status.logs);
return status;
}
function normalizeOfferDescription(description) {
if (description.type !== "offer" || !description.sdp || description.sdp.includes("a=msid:")) return description;
const lines = description.sdp.replace(/\r\n/g, "\n").split("\n");
const normalized = [];
let hasMsidSemantic = false;
let insertedVideoMsid = false;
for (const line of lines) {
if (line.startsWith("a=msid-semantic:")) hasMsidSemantic = true;
if (line.startsWith("m=video") && !hasMsidSemantic) {
normalized.push("a=msid-semantic: WMS jetson-camera");
hasMsidSemantic = true;
}
normalized.push(line);
if (!insertedVideoMsid && line.startsWith("a=mid:")) {
normalized.push("a=msid:jetson-camera video0");
insertedVideoMsid = true;
}
}
return { ...description, sdp: normalized.join("\r\n") };
}
function stopViewerOnly() {
if (statsTimer) window.clearInterval(statsTimer);
statsTimer = null;
if (image) image.removeAttribute("src");
if (video) video.srcObject = null;
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
if (ws && ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close();
if (pc) pc.close();
ws = null;
pc = null;
}
function showMjpeg(status) {
if (!image || !video) return;
video.style.display = "none";
image.style.display = "block";
const params = new URLSearchParams({
device: status.videoDevice || "/dev/video0",
width: String(status.width || 1920),
height: String(status.height || 1080),
framerate: String(status.framerate || 30),
t: String(Date.now()),
});
image.src = `/api/camera/mjpeg?${params.toString()}`;
image.onload = () => setState("MJPEG streaming");
image.onerror = () => setState("MJPEG stream failed");
}
async function startWebRtc(status) {
if (!video || !image) return;
image.style.display = "none";
video.style.display = "block";
pc = new RTCPeerConnection();
ws = new WebSocket(status.signalingUrl);
pc.ontrack = (event) => {
const stream = event.streams[0];
console.debug("[camera-web] track received", event.track.kind, event.track.readyState, event.track.muted);
if (!stream) return;
video.srcObject = stream;
video.play().catch((error) => console.error("[camera-web] video play failed", error));
setState("WebRTC streaming");
};
pc.onicecandidate = (event) => {
if (!event.candidate || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
type: "candidate",
roomId: status.roomId || "jetson-1",
role: "viewer",
candidate: event.candidate,
}));
};
pc.onconnectionstatechange = () => {
console.debug("[camera-web] connection state", pc.connectionState);
if (pc.connectionState === "connected") setState("WebRTC connected");
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setState("WebRTC failed");
};
ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === "offer") {
console.debug("[camera-web] offer received");
await pc.setRemoteDescription(normalizeOfferDescription(data.offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "answer",
roomId: status.roomId || "jetson-1",
role: "viewer",
answer,
}));
}
}
if (data.type === "candidate" && data.candidate) {
await pc.addIceCandidate(data.candidate);
}
};
await new Promise((resolve, reject) => {
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", roomId: status.roomId || "jetson-1", role: "viewer" }));
resolve();
};
ws.onerror = () => reject(new Error("signaling connection failed"));
});
setState("starting WebRTC sender");
await fetch("/api/camera/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: status.roomId || "jetson-1" }),
});
setState("waiting for WebRTC offer");
}
async function startCamera() {
stopViewerOnly();
setState("starting");
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
const status = await getStatus();
if (!status.enabled) {
setState("camera disabled");
return;
}
const mode = modeSelect?.value || "mjpeg";
if (mode === "webrtc") {
await startWebRtc(status);
} else {
setState("starting MJPEG stream");
showMjpeg(status);
}
}
async function stopCamera() {
stopViewerOnly();
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
setState("stopped");
await getStatus();
}
startButton?.addEventListener("click", () => startCamera().catch((error) => setState(error.message)));
stopButton?.addEventListener("click", () => stopCamera().catch((error) => setState(error.message)));
modeSelect?.addEventListener("change", () => startCamera().catch((error) => setState(error.message)));
getStatus().then((status) => {
if (!status.enabled) setState("camera disabled");
else startCamera().catch((error) => setState(error.message));
});
setInterval(() => {
getStatus().catch(() => undefined);
}, 3000);
+308
View File
@@ -0,0 +1,308 @@
:root {
color-scheme: light dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #05070a;
color: #f8fafc;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
background: #05070a;
}
body {
overflow: hidden;
background: #05070a;
}
body:has(.admin-shell) {
overflow: auto;
}
a {
color: inherit;
text-decoration: none;
}
.shell {
height: 100vh;
min-width: 320px;
background: #05070a;
}
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #05070a;
color-scheme: dark;
}
.setup {
display: grid;
min-height: 100%;
place-content: center;
gap: 10px;
padding: 24px;
text-align: center;
}
.setup h1 {
margin: 0;
font-size: 18px;
}
.setup p {
margin: 0;
color: #cbd5e1;
font-size: 14px;
}
.setup a {
justify-self: center;
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 6px;
background: #1f2937;
}
.camera-shell {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100vh;
background: #020617;
}
.camera-shell video,
.camera-shell img {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.camera-shell video {
display: none;
}
.camera-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 8px 10px;
border-top: 1px solid rgba(148, 163, 184, 0.22);
background: #111827;
}
.camera-panel strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.camera-panel button {
min-height: 30px;
padding: 6px 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 6px;
background: #1f2937;
color: #f8fafc;
}
.camera-panel pre {
grid-column: 1 / -1;
max-height: 96px;
margin: 0;
overflow: auto;
color: #cbd5e1;
font-size: 11px;
white-space: pre-wrap;
}
.admin-shell {
min-height: 100vh;
padding: 24px;
background: #0b0d10;
color: #eef2f7;
}
.admin-header {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
max-width: 1180px;
margin: 0 auto 18px;
}
.admin-header h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0;
}
.admin-header p {
max-width: 760px;
margin: 6px 0 0;
color: rgba(238, 242, 247, 0.58);
font-size: 13px;
line-height: 1.5;
}
.admin-header nav {
display: flex;
gap: 8px;
}
.admin-header nav a,
.config-form button {
min-height: 32px;
padding: 0 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 6px;
background: #1a2029;
color: #eef2f7;
font-size: 12px;
font-weight: 700;
}
.status-grid,
.config-form {
max-width: 1180px;
margin: 0 auto;
}
.status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.status-grid article,
.config-form section {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 6px;
background: #13171e;
}
.status-grid article {
min-height: 70px;
padding: 12px;
}
.status-grid span {
display: block;
margin-bottom: 7px;
color: rgba(238, 242, 247, 0.42);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.status-grid strong {
display: block;
overflow-wrap: anywhere;
color: rgba(238, 242, 247, 0.86);
font-size: 13px;
line-height: 1.35;
}
.config-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-bottom: 28px;
}
.config-form section {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-content: start;
padding: 14px;
}
.config-form h2 {
grid-column: 1 / -1;
margin: 0 0 2px;
color: rgba(238, 242, 247, 0.84);
font-size: 14px;
}
.config-form label {
display: grid;
gap: 5px;
min-width: 0;
color: rgba(238, 242, 247, 0.55);
font-size: 11px;
font-weight: 750;
}
.config-form label.inline {
display: flex;
align-items: center;
gap: 8px;
}
.config-form input,
.config-form select {
min-width: 0;
height: 32px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 5px;
background: #0d1117;
color: rgba(238, 242, 247, 0.88);
padding: 0 9px;
font: inherit;
}
.config-form input[type="checkbox"] {
width: 16px;
height: 16px;
padding: 0;
}
.config-form footer {
position: sticky;
bottom: 0;
grid-column: 1 / -1;
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
padding: 10px 0 0;
background: #0b0d10;
}
.config-form footer span {
min-width: 180px;
color: rgba(238, 242, 247, 0.58);
font-size: 12px;
}
@media (max-width: 920px) {
.admin-shell {
padding: 14px;
}
.admin-header {
flex-direction: column;
}
.status-grid,
.config-form,
.config-form section {
grid-template-columns: 1fr;
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda
WORKDIR /ros_bridge
RUN curl -fsSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
-o /usr/share/keyrings/ros-archive-keyring.gpg \
&& printf "deb [arch=%s signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu %s main\n" \
"$(dpkg --print-architecture)" "$(. /etc/os-release && echo "$UBUNTU_CODENAME")" \
> /etc/apt/sources.list.d/ros2.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ros-humble-autoware-adapi-v1-msgs \
ros-humble-autoware-planning-msgs \
ros-humble-autoware-vehicle-msgs \
ros-humble-nav2-msgs \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /ros_bridge/requirements.txt
RUN pip3 install --no-cache-dir -r /ros_bridge/requirements.txt
COPY bridge.py /ros_bridge/bridge.py
CMD bash -c "source /opt/ros/humble/setup.bash && if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi && python3 /ros_bridge/bridge.py"
+11
View File
@@ -0,0 +1,11 @@
FROM future-nav2:humble-jetson_orin
WORKDIR /ros_bridge
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
CMD ["python3", "/ros_bridge/bridge.py"]
+1963
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
pydantic>=2.0.0
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUTPUT_DIR="${PLATFORM_AGENT_RELEASE_DIR:-$ROOT_DIR/dist/release}"
STAGE_DIR="$(mktemp -d)"
cleanup() { rm -rf "$STAGE_DIR"; }
trap cleanup EXIT INT TERM
require_file() {
if [ ! -e "$ROOT_DIR/$1" ]; then
printf 'Missing required file: %s\n' "$1" >&2
exit 1
fi
}
for file in package.json bun.lock tsconfig.json agent.config.json src scripts/install.sh scripts/setting.sh scripts/uninstall.sh scripts/installer-tui/main.go; do
require_file "$file"
done
build_tui() {
arch="$1"
output="$ROOT_DIR/scripts/installer-tui/installer-tui-linux-$arch"
if command -v go >/dev/null 2>&1; then
(
cd "$ROOT_DIR/scripts/installer-tui"
GOOS=linux GOARCH="$arch" go build -o "$output" .
)
return
fi
if command -v docker >/dev/null 2>&1; then
docker run --rm \
--user "$(id -u):$(id -g)" \
-e GOCACHE=/tmp/go-build-cache \
-e GOOS=linux \
-e GOARCH="$arch" \
-v "$ROOT_DIR/scripts/installer-tui:/src" \
-w /src \
golang:1.22-alpine \
sh -c "go build -o installer-tui-linux-$arch ."
return
fi
printf '%s\n' 'Go or Docker is required to build installer binaries.' >&2
exit 1
}
package_archive() {
arch="$1"
package_dir="$STAGE_DIR/platform-agent"
archive="$OUTPUT_DIR/platform-agent-linux-$arch.tar.gz"
rm -rf "$package_dir"
mkdir -p "$package_dir/scripts/installer-tui"
cp "$ROOT_DIR/package.json" "$ROOT_DIR/bun.lock" "$ROOT_DIR/tsconfig.json" "$ROOT_DIR/agent.config.json" "$package_dir/"
cp -R "$ROOT_DIR/src" "$package_dir/src"
cp -R "$ROOT_DIR/scripts/." "$package_dir/scripts/"
rm -f "$package_dir/scripts/installer-tui/installer-tui" \
"$package_dir/scripts/installer-tui/installer-tui-linux-amd64" \
"$package_dir/scripts/installer-tui/installer-tui-linux-arm64"
cp "$ROOT_DIR/scripts/installer-tui/installer-tui-linux-$arch" "$package_dir/scripts/installer-tui/"
chmod +x "$package_dir/scripts/install.sh" "$package_dir/scripts/setting.sh" "$package_dir/scripts/uninstall.sh" \
"$package_dir/scripts/installer-tui/installer-tui-linux-$arch"
tar -C "$STAGE_DIR" -czf "$archive" platform-agent
printf 'Created %s\n' "$archive"
}
mkdir -p "$OUTPUT_DIR"
build_tui amd64
build_tui arm64
package_archive amd64
package_archive arm64
if command -v sha256sum >/dev/null 2>&1; then
(
cd "$OUTPUT_DIR"
sha256sum platform-agent-linux-amd64.tar.gz platform-agent-linux-arm64.tar.gz > checksums.txt
)
printf 'Created %s/checksums.txt\n' "$OUTPUT_DIR"
fi
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env sh
set -eu
INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}"
SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}"
REPO_URL="${PLATFORM_AGENT_REPO_URL:-}"
RUN_USER="${PLATFORM_AGENT_USER:-$(id -un)}"
INSTALL_TAILSCALE="${INSTALL_TAILSCALE:-false}"
INSTALL_OPENSSH_SERVER="${INSTALL_OPENSSH_SERVER:-true}"
INSTALL_GIT="${INSTALL_GIT:-true}"
TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:-}"
TAILSCALE_LOGIN_SERVER="${TAILSCALE_LOGIN_SERVER:-}"
TAILSCALE_HOSTNAME="${TAILSCALE_HOSTNAME:-}"
TAILSCALE_EXTRA_ARGS="${TAILSCALE_EXTRA_ARGS:-}"
PLATFORM_PROVISION_URL="${PLATFORM_PROVISION_URL:-https://portal-api.autonomystudio.net/api/provision}"
PLATFORM_ACCOUNT="${PLATFORM_ACCOUNT:-}"
PLATFORM_REGISTRATION_CODE="${PLATFORM_REGISTRATION_CODE:-}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-~/autonomy-studio/workspaces}"
export WORKSPACE_ROOT
ROBOT_STACK="${ROBOT_STACK:-autoware}"
ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}"
CAMERA_ENABLED="${CAMERA_ENABLED:-false}"
CAMERA_VIDEO_DEVICE="${CAMERA_VIDEO_DEVICE:-/dev/video0}"
CAMERA_SIGNALING_URL="${CAMERA_SIGNALING_URL:-ws://127.0.0.1:3000}"
KASMVNC_GEOMETRY="${KASMVNC_GEOMETRY:-1600x900}"
AGENT_GATEWAY_TOKEN="${AGENT_GATEWAY_TOKEN:-}"
PLATFORM_AGENT_INTERACTIVE="${PLATFORM_AGENT_INTERACTIVE:-auto}"
PLATFORM_INSTALLER_TUI_URL="${PLATFORM_INSTALLER_TUI_URL:-}"
PLATFORM_SKIP_TUI="${PLATFORM_SKIP_TUI:-false}"
PLATFORM_AGENT_REINSTALL="${PLATFORM_AGENT_REINSTALL:-false}"
PLATFORM_AGENT_SETTINGS_ONLY="${PLATFORM_AGENT_SETTINGS_ONLY:-false}"
STEP_CURRENT=0
STEP_TOTAL=8
TUI_SKIP_REASON=""
log() {
printf '%s\n' "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
has_tty() {
[ -t 0 ] || { [ -r /dev/tty ] && [ -w /dev/tty ]; }
}
tty_available() {
( : < /dev/tty > /dev/tty ) 2>/dev/null
}
progress_bar() {
current="$1"
total="$2"
label="$3"
width=24
filled=$((current * width / total))
empty=$((width - filled))
bar=""
i=0
while [ "$i" -lt "$filled" ]; do bar="${bar}#"; i=$((i + 1)); done
i=0
while [ "$i" -lt "$empty" ]; do bar="${bar}-"; i=$((i + 1)); done
printf '[%s] %s/%s %s\n' "$bar" "$current" "$total" "$label"
}
run_step() {
STEP_CURRENT=$((STEP_CURRENT + 1))
progress_bar "$STEP_CURRENT" "$STEP_TOTAL" "$1"
shift
"$@"
}
as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif need_cmd sudo; then
sudo "$@"
else
log "Root privileges are required: $*"
exit 1
fi
}
prompt_yes_no() {
label="$1"
default="${2:-no}"
suffix="[y/N]"
[ "$default" = "yes" ] && suffix="[Y/n]"
if tty_available; then
printf '%s %s ' "$label" "$suffix" > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
else
printf '%s %s ' "$label" "$suffix"
IFS= read -r answer || answer=""
fi
answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')"
if [ -z "$answer" ]; then
[ "$default" = "yes" ]
return
fi
[ "$answer" = "y" ] || [ "$answer" = "yes" ]
}
agent_already_installed() {
[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] || [ -f "${INSTALL_DIR}/agent.config.json" ]
}
handle_existing_install() {
case "$PLATFORM_AGENT_REINSTALL" in
true|1|yes|y|on)
return
;;
esac
case "$PLATFORM_AGENT_SETTINGS_ONLY" in
true|1|yes|y|on)
if [ -x "${INSTALL_DIR}/scripts/setting.sh" ]; then
exec "${INSTALL_DIR}/scripts/setting.sh"
fi
exec "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/setting.sh"
;;
esac
if ! agent_already_installed; then
return
fi
log "Autonomy Studio Platform Agent is already installed."
log "Install dir : ${INSTALL_DIR}"
log "Service : ${SERVICE_NAME}"
log ""
if ! interactive_allowed; then
log "Set PLATFORM_AGENT_REINSTALL=true to reinstall, or PLATFORM_AGENT_SETTINGS_ONLY=true to edit settings."
exit 0
fi
log "1. Open settings"
log "2. Reinstall / update"
log "3. Cancel"
log ""
if tty_available; then
printf 'Select [1]: ' > /dev/tty
IFS= read -r choice < /dev/tty || choice=""
else
printf 'Select [1]: '
IFS= read -r choice || choice=""
fi
case "${choice:-1}" in
1)
if [ -x "${INSTALL_DIR}/scripts/setting.sh" ]; then
exec "${INSTALL_DIR}/scripts/setting.sh"
fi
exec "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/setting.sh"
;;
2)
if prompt_yes_no "Continue reinstall/update?" "no"; then
return
fi
log "Canceled."
exit 0
;;
*)
log "Canceled."
exit 0
;;
esac
}
install_bun() {
if need_cmd bun; then
return
fi
log "Installing Bun."
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
}
interactive_allowed() {
case "$PLATFORM_AGENT_INTERACTIVE" in
false|0|no|n|off)
return 1
;;
true|1|yes|y|on)
has_tty
return
;;
*)
has_tty
return
;;
esac
}
prompt_value() {
var_name="$1"
label="$2"
current_value="$3"
secret="${4:-false}"
if [ "$secret" = "true" ]; then
if tty_available; then
printf '%s [%s]: ' "$label" "hidden" > /dev/tty
stty -echo < /dev/tty || true
IFS= read -r answer < /dev/tty || answer=""
stty echo < /dev/tty || true
printf '\n' > /dev/tty
else
printf '%s [%s]: ' "$label" "hidden"
stty -echo || true
IFS= read -r answer || answer=""
stty echo || true
printf '\n'
fi
else
if tty_available; then
printf '%s [%s]: ' "$label" "$current_value" > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
else
printf '%s [%s]: ' "$label" "$current_value"
IFS= read -r answer || answer=""
fi
fi
if [ -n "$answer" ]; then
eval "$var_name=\"\$answer\""
fi
}
run_shell_tui() {
if ! interactive_allowed; then
return 1
fi
clear
log "┌────────────────────────────────────────────────────────────────────┐"
log "│ Autonomy Studio Platform Agent Installer │"
log "└────────────────────────────────────────────────────────────────────┘"
log ""
prompt_value PLATFORM_ACCOUNT "Autonomy Studio account" "$PLATFORM_ACCOUNT"
prompt_value PLATFORM_REGISTRATION_CODE "6-digit registration code" "$PLATFORM_REGISTRATION_CODE"
prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT"
prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID"
prompt_value CAMERA_ENABLED "Enable camera true/false" "$CAMERA_ENABLED"
prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE"
prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL"
prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY"
provision_tailscale_from_backend
return 0
}
run_bubbletea_tui() {
case "$PLATFORM_SKIP_TUI" in
true|1|yes|y|on)
TUI_SKIP_REASON="PLATFORM_SKIP_TUI is enabled."
return 1
;;
esac
if ! interactive_allowed; then
TUI_SKIP_REASON="Interactive terminal is not available or PLATFORM_AGENT_INTERACTIVE=false."
return 1
fi
env_file="$(mktemp)"
tui_bin=""
current_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd || printf '')"
tui_arch=""
case "$(uname -m 2>/dev/null || true)" in
x86_64|amd64) tui_arch="amd64" ;;
aarch64|arm64) tui_arch="arm64" ;;
esac
if [ -n "$PLATFORM_INSTALLER_TUI_URL" ]; then
tui_url="$PLATFORM_INSTALLER_TUI_URL"
if [ -n "$tui_arch" ]; then
tui_url="$(printf '%s' "$tui_url" | sed "s/{arch}/$tui_arch/g")"
fi
tui_bin="$(mktemp)"
curl -fsSL "$tui_url" -o "$tui_bin"
chmod +x "$tui_bin"
set +e
if tty_available; then
"$tui_bin" "$env_file" < /dev/tty > /dev/tty
else
"$tui_bin" "$env_file"
fi
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 130 ]; then
rm -f "$env_file" "$tui_bin"
exit 130
fi
TUI_SKIP_REASON="Failed to run the TUI downloaded from PLATFORM_INSTALLER_TUI_URL."
rm -f "$env_file" "$tui_bin"
return 1
fi
elif [ -n "$current_dir" ] && [ -n "$tui_arch" ] && [ -x "$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" ]; then
set +e
if tty_available; then
"$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" "$env_file" < /dev/tty > /dev/tty
else
"$current_dir/scripts/installer-tui/installer-tui-linux-$tui_arch" "$env_file"
fi
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 130 ]; then
rm -f "$env_file"
exit 130
fi
TUI_SKIP_REASON="Failed to run the local installer-tui binary."
rm -f "$env_file"
return 1
fi
elif [ -n "$current_dir" ] && [ -x "$current_dir/scripts/installer-tui/installer-tui" ]; then
# Compatibility with existing single-architecture installer packages.
set +e
if tty_available; then
"$current_dir/scripts/installer-tui/installer-tui" "$env_file" < /dev/tty > /dev/tty
else
"$current_dir/scripts/installer-tui/installer-tui" "$env_file"
fi
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 130 ]; then
rm -f "$env_file"
exit 130
fi
TUI_SKIP_REASON="Failed to run the local installer-tui binary."
rm -f "$env_file"
return 1
fi
elif [ -n "$current_dir" ] && [ -f "$current_dir/scripts/installer-tui/main.go" ] && need_cmd go; then
set +e
if tty_available; then
(cd "$current_dir/scripts/installer-tui" && go run . "$env_file") < /dev/tty > /dev/tty
else
(cd "$current_dir/scripts/installer-tui" && go run . "$env_file")
fi
status=$?
set -e
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 130 ]; then
rm -f "$env_file"
exit 130
fi
TUI_SKIP_REASON="Failed to run the Bubble Tea TUI with go run."
rm -f "$env_file"
return 1
fi
else
TUI_SKIP_REASON="No TUI binary or Go runtime found. curl | sh installs require PLATFORM_INSTALLER_TUI_URL."
rm -f "$env_file"
return 1
fi
# shellcheck disable=SC1090
. "$env_file"
INSTALL_DIR="${PLATFORM_AGENT_DIR:-$INSTALL_DIR}"
SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-$SERVICE_NAME}"
REPO_URL="${PLATFORM_AGENT_REPO_URL:-$REPO_URL}"
RUN_USER="${PLATFORM_AGENT_USER:-$RUN_USER}"
rm -f "$env_file"
[ -n "${tui_bin:-}" ] && rm -f "$tui_bin"
return 0
}
collect_install_config() {
if run_bubbletea_tui; then
return
fi
if run_shell_tui; then
return
fi
if ! interactive_allowed; then
return
fi
log "Bubble Tea TUI is unavailable. Falling back to shell prompts."
if [ -n "$TUI_SKIP_REASON" ]; then
log "Reason: $TUI_SKIP_REASON"
fi
prompt_value PLATFORM_ACCOUNT "Autonomy Studio account" "$PLATFORM_ACCOUNT"
prompt_value PLATFORM_REGISTRATION_CODE "6-digit registration code" "$PLATFORM_REGISTRATION_CODE"
prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT"
prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID"
prompt_value CAMERA_ENABLED "Enable camera true/false" "$CAMERA_ENABLED"
prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE"
prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL"
prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY"
provision_tailscale_from_backend
}
provision_tailscale_from_backend() {
if [ -z "$PLATFORM_ACCOUNT" ] || [ -z "$PLATFORM_REGISTRATION_CODE" ]; then
log "Provisioning requires both account and registration code."
exit 1
fi
if [ -z "$PLATFORM_PROVISION_URL" ]; then
log "PLATFORM_PROVISION_URL is required for account provisioning."
exit 1
fi
if ! need_cmd curl; then
log "Provisioning requires curl."
exit 1
fi
if ! need_cmd python3; then
log "Shell fallback provisioning requires python3. Use the TUI binary or pass environment variables directly."
exit 1
fi
body_file="$(mktemp)"
resp_file="$(mktemp)"
env_file="$(mktemp)"
legacy_body_file="$(mktemp)"
chmod 600 "$body_file" "$legacy_body_file" "$resp_file" "$env_file"
PLATFORM_ACCOUNT="$PLATFORM_ACCOUNT" PLATFORM_REGISTRATION_CODE="$PLATFORM_REGISTRATION_CODE" RUN_USER="$RUN_USER" WORKSPACE_ROOT="$WORKSPACE_ROOT" python3 -c 'import json, os, platform
hostname = os.uname().nodename
distro = platform.platform()
ssh_user = os.environ.get("RUN_USER") or os.environ.get("USER") or ""
workspace_root = os.environ.get("WORKSPACE_ROOT") or "~/autonomy-studio/workspaces"
try:
with open("/etc/os-release", "r", encoding="utf-8") as fp:
for line in fp:
if line.startswith("PRETTY_NAME="):
distro = line.split("=", 1)[1].strip().strip("\"")
break
except OSError:
pass
device = {
"hostname": hostname,
"os": os.uname().sysname.lower(),
"arch": {"x86_64": "amd64", "AMD64": "amd64", "aarch64": "arm64", "armv8": "arm64"}.get(platform.machine(), platform.machine()),
"kernel": os.uname().release,
"distro": distro,
"sshUser": ssh_user,
"workspaceRoot": workspace_root,
}
print(json.dumps({
"account": os.environ["PLATFORM_ACCOUNT"],
"userCode": os.environ["PLATFORM_REGISTRATION_CODE"],
"hostname": hostname,
"os": device["os"],
"arch": device["arch"],
"kernel": device["kernel"],
"distro": device["distro"],
"sshUser": ssh_user,
"workspaceRoot": workspace_root,
"device": device,
}))' > "$body_file"
PLATFORM_ACCOUNT="$PLATFORM_ACCOUNT" PLATFORM_REGISTRATION_CODE="$PLATFORM_REGISTRATION_CODE" RUN_USER="$RUN_USER" WORKSPACE_ROOT="$WORKSPACE_ROOT" python3 -c 'import json, os
hostname = os.uname().nodename
print(json.dumps({
"account": os.environ["PLATFORM_ACCOUNT"],
"userCode": os.environ["PLATFORM_REGISTRATION_CODE"],
"hostname": hostname,
"sshUser": os.environ.get("RUN_USER") or os.environ.get("USER") or "",
"workspaceRoot": os.environ.get("WORKSPACE_ROOT") or "~/autonomy-studio/workspaces",
}))' > "$legacy_body_file"
http_status="$(curl -sS -H "Content-Type: application/json" --data-binary "@$body_file" "$PLATFORM_PROVISION_URL/device-authorizations/consume" -o "$resp_file" -w "%{http_code}" || printf '000')"
if [ "$http_status" = "400" ]; then
http_status="$(curl -sS -H "Content-Type: application/json" --data-binary "@$legacy_body_file" "$PLATFORM_PROVISION_URL/device-authorizations/consume" -o "$resp_file" -w "%{http_code}" || printf '000')"
fi
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
rm -f "$body_file" "$legacy_body_file" "$resp_file" "$env_file"
log "Provisioning request failed."
exit 1
fi
python3 - "$resp_file" > "$env_file" <<'PY'
import json
import shlex
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fp:
data = json.load(fp)
def first(*keys):
for key in keys:
value = data.get(key)
if value is not None and value != "":
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
return ""
values = {
"INSTALL_TAILSCALE": first("installTailscale", "install_tailscale", "tailscaleEnabled", "tailscale_enabled"),
"TAILSCALE_AUTH_KEY": first("tailscaleAuthKey", "tailscale_auth_key", "authKey", "auth_key"),
"TAILSCALE_LOGIN_SERVER": first("tailscaleLoginServer", "tailscale_login_server", "loginServer", "login_server"),
"TAILSCALE_HOSTNAME": first("tailscaleHostname", "tailscale_hostname", "hostname"),
"TAILSCALE_EXTRA_ARGS": first("tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"),
"AGENT_GATEWAY_TOKEN": first("agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"),
}
if not values["INSTALL_TAILSCALE"] and values["TAILSCALE_AUTH_KEY"]:
values["INSTALL_TAILSCALE"] = "true"
for key, value in values.items():
if value:
print(f"{key}={shlex.quote(value)}")
PY
# shellcheck disable=SC1090
. "$env_file"
rm -f "$body_file" "$legacy_body_file" "$resp_file" "$env_file"
}
install_tailscale() {
case "$INSTALL_TAILSCALE" in
true|1|yes|y|on) ;;
*)
return
;;
esac
if [ "$(uname -s)" != "Linux" ]; then
log "Automatic Tailscale installation is supported only on Linux. Skipping."
return
fi
if ! need_cmd tailscale; then
log "Installing Tailscale."
curl -fsSL https://tailscale.com/install.sh | sh
fi
if need_cmd systemctl; then
as_root systemctl enable --now tailscaled
elif need_cmd service; then
as_root service tailscaled start || true
fi
if [ -n "$TAILSCALE_AUTH_KEY" ]; then
log "Joining the Tailscale network."
set -- up --auth-key "$TAILSCALE_AUTH_KEY"
if [ -n "$TAILSCALE_LOGIN_SERVER" ]; then
set -- "$@" --login-server "$TAILSCALE_LOGIN_SERVER"
fi
if [ -n "$TAILSCALE_HOSTNAME" ]; then
set -- "$@" --hostname "$TAILSCALE_HOSTNAME"
fi
if [ -n "$TAILSCALE_EXTRA_ARGS" ]; then
# shellcheck disable=SC2086
set -- "$@" $TAILSCALE_EXTRA_ARGS
fi
tailscale_error_file="$(mktemp)"
if ! as_root tailscale "$@" 2>"$tailscale_error_file"; then
tailscale_error="$(cat "$tailscale_error_file" 2>/dev/null || true)"
rm -f "$tailscale_error_file"
case "$tailscale_error" in
*"requires mentioning all"*|*"--reset"*)
log "Existing Tailscale settings detected. Retrying with --reset."
set -- "$@" --reset
as_root tailscale "$@"
;;
*)
printf '%s\n' "$tailscale_error" >&2
exit 1
;;
esac
else
rm -f "$tailscale_error_file"
fi
else
log "TAILSCALE_AUTH_KEY is empty. tailscaled was enabled but not joined."
fi
if [ "$RUN_USER" != "root" ]; then
log "Allowing ${RUN_USER} to run Tailscale without root."
as_root tailscale set --operator="$RUN_USER" || log "Failed to set Tailscale operator for ${RUN_USER}."
fi
}
install_openssh_server() {
case "$INSTALL_OPENSSH_SERVER" in
true|1|yes|y|on) ;;
*)
return
;;
esac
if [ "$(uname -s)" != "Linux" ]; then
log "Automatic OpenSSH server installation is supported only on Linux. Skipping."
return
fi
if need_cmd sshd || [ -x /usr/sbin/sshd ]; then
log "OpenSSH server is already installed."
elif need_cmd apt-get; then
log "Installing OpenSSH server with apt."
as_root apt-get update
as_root apt-get install -y openssh-server
elif need_cmd dnf; then
log "Installing OpenSSH server with dnf."
as_root dnf install -y openssh-server
elif need_cmd yum; then
log "Installing OpenSSH server with yum."
as_root yum install -y openssh-server
elif need_cmd pacman; then
log "Installing OpenSSH server with pacman."
as_root pacman -Sy --noconfirm openssh
elif need_cmd zypper; then
log "Installing OpenSSH server with zypper."
as_root zypper --non-interactive install openssh
else
log "No supported package manager found. Install OpenSSH server manually."
return
fi
if need_cmd systemctl; then
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
as_root systemctl enable --now ssh
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
as_root systemctl enable --now sshd
else
as_root systemctl restart ssh || as_root systemctl restart sshd || true
fi
elif need_cmd service; then
as_root service ssh start || as_root service sshd start || true
fi
}
install_git() {
case "$INSTALL_GIT" in
true|1|yes|y|on) ;;
*)
return
;;
esac
if need_cmd git; then
log "Git is already installed."
return
fi
if [ "$(uname -s)" != "Linux" ]; then
log "Automatic Git installation is supported only on Linux. Skipping."
return
fi
if need_cmd apt-get; then
log "Installing Git with apt."
as_root apt-get update
as_root apt-get install -y git
elif need_cmd dnf; then
log "Installing Git with dnf."
as_root dnf install -y git
elif need_cmd yum; then
log "Installing Git with yum."
as_root yum install -y git
elif need_cmd pacman; then
log "Installing Git with pacman."
as_root pacman -Sy --noconfirm git
elif need_cmd zypper; then
log "Installing Git with zypper."
as_root zypper --non-interactive install git
else
log "No supported package manager found. Install Git manually."
fi
}
prepare_sources() {
if [ -n "$REPO_URL" ]; then
as_root mkdir -p "$INSTALL_DIR"
if [ -d "$INSTALL_DIR/.git" ]; then
as_root git -C "$INSTALL_DIR" pull --ff-only
else
tmp_dir="$(mktemp -d)"
git clone "$REPO_URL" "$tmp_dir"
as_root cp -a "$tmp_dir/." "$INSTALL_DIR/"
rm -rf "$tmp_dir"
fi
else
current_dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
as_root mkdir -p "$INSTALL_DIR"
as_root cp -a "$current_dir/." "$INSTALL_DIR/"
fi
as_root chown -R "$RUN_USER":"$RUN_USER" "$INSTALL_DIR"
}
install_dependencies() {
(cd "$INSTALL_DIR" && bun install)
}
write_agent_config_from_env() {
(
cd "$INSTALL_DIR"
AGENT_CONFIG_PATH="$INSTALL_DIR/agent.config.json" \
WORKSPACE_ROOT="$WORKSPACE_ROOT" \
ROBOT_STACK="$ROBOT_STACK" \
ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \
CAMERA_ENABLED="$CAMERA_ENABLED" \
CAMERA_VIDEO_DEVICE="$CAMERA_VIDEO_DEVICE" \
CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \
KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \
AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \
bun -e '
const fs = require("fs");
const path = process.env.AGENT_CONFIG_PATH;
const cfg = JSON.parse(fs.readFileSync(path, "utf8"));
cfg.security ??= {};
cfg.workspace ??= {};
cfg.ros ??= {};
cfg.camera ??= {};
cfg.desktop ??= {};
cfg.security.gatewayToken = process.env.AGENT_GATEWAY_TOKEN || cfg.security.gatewayToken || "";
cfg.workspace.root = process.env.WORKSPACE_ROOT || cfg.workspace.root || "~/autonomy-studio/workspaces";
cfg.ros.stack = process.env.ROBOT_STACK === "nav2" ? "nav2" : "autoware";
cfg.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID || "") ? process.env.ROS_DOMAIN_ID : "0";
cfg.camera.enabled = /^(true|1|yes|y|on)$/i.test(process.env.CAMERA_ENABLED || "");
cfg.camera.videoDevice = process.env.CAMERA_VIDEO_DEVICE || cfg.camera.videoDevice || "/dev/video0";
cfg.camera.signalingUrl = process.env.CAMERA_SIGNALING_URL || cfg.camera.signalingUrl || "ws://127.0.0.1:3000";
cfg.desktop.geometry = process.env.KASMVNC_GEOMETRY || cfg.desktop.geometry || "1600x900";
fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
'
)
}
write_service() {
service_path="/etc/systemd/system/${SERVICE_NAME}.service"
tmp_service="$(mktemp)"
bun_bin="$(command -v bun)"
cat > "$tmp_service" <<EOF
[Unit]
Description=Platform Agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${RUN_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${bun_bin} src/server.tsx
Restart=always
RestartSec=2
Environment=HOST=0.0.0.0
Environment=AGENT_CONFIG=${INSTALL_DIR}/agent.config.json
Environment=AGENT_SSH_USER=${RUN_USER}
[Install]
WantedBy=multi-user.target
EOF
as_root cp "$tmp_service" "$service_path"
rm -f "$tmp_service"
as_root systemctl daemon-reload
as_root systemctl enable "$SERVICE_NAME"
as_root systemctl restart "$SERVICE_NAME"
}
print_host_notes() {
log ""
log "Install complete: ${INSTALL_DIR}"
log "Service: systemctl status ${SERVICE_NAME}"
log "Settings UI: http://<device-ip>:3100"
if need_cmd tailscale; then
ts_ip="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
if [ -n "$ts_ip" ]; then
log "Tailscale Settings UI: http://${ts_ip}:3100"
fi
fi
log ""
log "Automatic Tailscale install example:"
log "INSTALL_TAILSCALE=true TAILSCALE_AUTH_KEY=tskey-auth-... sh scripts/install.sh"
log ""
log "Optional host dependencies:"
log "- KasmVNC/XFCE: vncserver or kasmvncserver, XFCE"
log "- Camera: GStreamer, v4l-utils, python3-gi, websockets"
log "- ROS bridge: ROS2 Humble, FastAPI requirements, Autoware/Nav2 message packages"
log "- Docker module runtime: docker, nvidia-container-toolkit, or the device-specific GPU runtime"
}
handle_existing_install
collect_install_config
run_step "Install Tailscale if requested" install_tailscale
run_step "Install OpenSSH server if needed" install_openssh_server
run_step "Install Git if needed" install_git
run_step "Install Bun if needed" install_bun
run_step "Prepare sources" prepare_sources
run_step "Install dependencies" install_dependencies
run_step "Write agent config" write_agent_config_from_env
run_step "Install systemd service" write_service
print_host_notes
+39
View File
@@ -0,0 +1,39 @@
module platform-agent-installer-tui
go 1.22
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.4.5 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/yuin/goldmark v1.7.4 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
)
+76
View File
@@ -0,0 +1,76 @@
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=
github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
Binary file not shown.
Binary file not shown.
Binary file not shown.
+946
View File
@@ -0,0 +1,946 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
type question struct {
key string
title string
prompt string
def string
secret bool
example string
options []string
}
var questions = []question{
{"PLATFORM_ACCOUNT", "Autonomy Studio account", "Enter the email address used in Autonomy Studio.", "", false, "name@example.com", nil},
{"PLATFORM_REGISTRATION_CODE", "Registration code", "Enter the 6-digit code created in Launcher Platform management.", "", false, "000000", nil},
{"USAGE_TYPE", "Usage type", "Select how this device will be used.", "개인용", false, "", []string{"개인용", "교보재용"}},
{"DEVICE_HOSTNAME", "Device hostname", "Hostname to register this device with. Press Enter to keep the detected hostname.", "", false, "", nil},
{"WORKSPACE_ROOT", "Workspace", "Which directory should the platform expose as the workspace?", "~/autonomy-studio/workspaces", false, "", nil},
{"CAMERA_VIDEO_DEVICE", "Video device", "Which V4L2 camera device should be used?", "/dev/video0", false, "/dev/video0", nil},
}
type model struct {
input textinput.Model
envPath string
index int
values map[string]string
choices map[string]int
provision map[string]string
provisionSummary string
showProvision bool
loading bool
err error
done bool
width int
height int
}
type provisionResultMsg struct {
values map[string]string
summary string
err error
}
var (
accent = lipgloss.Color("81")
accentStrong = lipgloss.Color("87")
muted = lipgloss.Color("245")
panelBorder = lipgloss.Color("240")
panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(panelBorder).Padding(1, 2)
brandStyle = lipgloss.NewStyle().Bold(true).Foreground(accentStrong)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255"))
promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255"))
valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229"))
mutedStyle = lipgloss.NewStyle().Foreground(muted)
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42"))
inputStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1)
keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Padding(0, 1)
optionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Padding(0, 1)
)
func init() {
lipgloss.SetColorProfile(termenv.ANSI256)
}
func envDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func initialModel(envPath string) model {
input := textinput.New()
input.Prompt = ""
input.CharLimit = 512
input.Width = 56
input.Focus()
values := map[string]string{}
m := model{input: input, envPath: envPath, values: values, choices: map[string]int{}, provision: map[string]string{}, width: 86, height: 24}
m.loadQuestion()
return m
}
func (m *model) loadQuestion() {
q := questions[m.index]
value := envDefault(q.key, questionDefault(q))
if existing, ok := m.values[q.key]; ok {
value = existing
}
if len(q.options) > 0 {
m.choices[q.key] = optionIndex(q.options, value)
m.input.SetValue("")
return
}
if _, ok := m.values[q.key]; ok {
m.input.SetValue(value)
} else if q.key == "DEVICE_HOSTNAME" || q.key == "WORKSPACE_ROOT" {
m.input.SetValue(value)
} else {
m.input.SetValue("")
}
m.input.Placeholder = q.example
m.input.EchoMode = textinput.EchoNormal
if q.secret {
m.input.EchoMode = textinput.EchoPassword
m.input.EchoCharacter = '*'
}
}
func questionDefault(q question) string {
if q.key == "DEVICE_HOSTNAME" {
hostname, _ := os.Hostname()
if strings.TrimSpace(hostname) != "" {
return strings.TrimSpace(hostname)
}
}
if q.key == "WORKSPACE_ROOT" {
return envDefault("WORKSPACE_ROOT", "~/autonomy-studio/workspaces")
}
return q.def
}
func questionExample(q question) string {
return q.example
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case provisionResultMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.err = nil
m.provision = msg.values
m.provisionSummary = msg.summary
m.showProvision = true
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.input.Width = max(28, min(64, msg.Width-24))
case tea.KeyMsg:
if isTerminalColorResponse(msg.String()) {
return m, nil
}
if m.loading {
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
}
return m, nil
}
if m.showProvision {
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter":
m.showProvision = false
m.index++
m.loadQuestion()
return m, nil
case "left":
m.showProvision = false
m.index = 2
m.loadQuestion()
return m, nil
}
return m, nil
}
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter":
q := questions[m.index]
value := m.currentValue()
if err := m.validate(q, value); err != nil {
m.err = err
break
}
m.err = nil
m.values[q.key] = value
if q.key == "WORKSPACE_ROOT" {
account := strings.TrimSpace(m.values["PLATFORM_ACCOUNT"])
registrationCode := strings.TrimSpace(m.values["PLATFORM_REGISTRATION_CODE"])
usageType := strings.TrimSpace(m.values["USAGE_TYPE"])
deviceHostname := strings.TrimSpace(m.values["DEVICE_HOSTNAME"])
workspaceRoot := strings.TrimSpace(value)
m.loading = true
return m, provisionWithRegistrationCode(account, registrationCode, usageType, deviceHostname, workspaceRoot, m.width)
}
if m.index == len(questions)-1 {
m.err = m.writeEnv()
m.done = m.err == nil
return m, tea.Quit
}
m.index++
m.loadQuestion()
case "backspace", "ctrl+h", "ctrl+?":
m.err = nil
case "up":
if len(questions[m.index].options) > 0 {
m.prevChoice()
}
case "down":
if len(questions[m.index].options) > 0 {
m.nextChoice()
}
case "left":
if m.index > 0 {
m.values[questions[m.index].key] = m.currentValue()
m.index--
m.loadQuestion()
m.err = nil
return m, nil
}
}
}
if len(questions[m.index].options) > 0 {
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.input.SetValue(sanitizeInput(m.input.Value()))
return m, cmd
}
func (m model) currentValue() string {
q := questions[m.index]
if len(q.options) > 0 {
index := m.choices[q.key]
if index < 0 || index >= len(q.options) {
index = optionIndex(q.options, questionDefault(q))
}
return q.options[index]
}
value := strings.TrimSpace(sanitizeInput(m.input.Value()))
if value == "" {
return questionDefault(q)
}
return value
}
func (m *model) nextChoice() {
q := questions[m.index]
if len(q.options) == 0 {
return
}
m.choices[q.key] = (m.choices[q.key] + 1) % len(q.options)
m.err = nil
}
func (m *model) prevChoice() {
q := questions[m.index]
if len(q.options) == 0 {
return
}
next := m.choices[q.key] - 1
if next < 0 {
next = len(q.options) - 1
}
m.choices[q.key] = next
m.err = nil
}
func optionIndex(options []string, value string) int {
for i, option := range options {
if strings.EqualFold(option, value) {
return i
}
}
return 0
}
func (m model) validate(q question, value string) error {
if q.key == "PLATFORM_ACCOUNT" && !strings.Contains(value, "@") {
return fmt.Errorf("Enter the account email address.")
}
if q.key == "PLATFORM_REGISTRATION_CODE" && !regexp.MustCompile(`^\d{6}$`).MatchString(strings.TrimSpace(value)) {
return fmt.Errorf("Enter the 6-digit registration code.")
}
return nil
}
func isTerminalColorResponse(value string) bool {
return strings.Contains(value, "rgb:") || strings.HasPrefix(value, "]10;") || strings.HasPrefix(value, "]11;")
}
func sanitizeInput(value string) string {
value = stripAfter(value, "\x1b]10;")
value = stripAfter(value, "\x1b]11;")
value = stripAfter(value, "]10;")
value = stripAfter(value, "]11;")
value = stripAfter(value, "rgb:")
if value == "]" || value == "\\" || value == "\x1b\\" {
return ""
}
return strings.ReplaceAll(value, "\x1b\\", "")
}
func stripAfter(value string, marker string) string {
if index := strings.Index(value, marker); index >= 0 {
return value[:index]
}
return value
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func provisionDevice(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd {
return func() tea.Msg {
values, summary, err := fetchProvision(account, password, usageType, deviceHostname, workspaceRoot, width)
return provisionResultMsg{values: values, summary: summary, err: err}
}
}
func buildProvisionDevice(deviceHostname string, workspaceRoot string, usageType string) map[string]string {
device := detectDevice()
if strings.TrimSpace(deviceHostname) != "" {
device["hostname"] = strings.TrimSpace(deviceHostname)
}
sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", ""))
if sshUser != "" {
device["sshUser"] = sshUser
}
device["workspaceRoot"] = strings.TrimSpace(workspaceRoot)
device["usageType"] = normalizeUsageType(usageType)
return device
}
func provisionWithRegistrationCode(account string, registrationCode string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd {
return func() tea.Msg {
account = strings.TrimSpace(account)
registrationCode = strings.TrimSpace(registrationCode)
usageType = normalizeUsageType(usageType)
if strings.TrimSpace(workspaceRoot) == "" {
workspaceRoot = "~/autonomy-studio/workspaces"
}
if account == "" || registrationCode == "" {
return provisionResultMsg{err: fmt.Errorf("Autonomy Studio account and registration code are required.")}
}
baseURL := strings.TrimRight(strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision")), "/")
if baseURL == "" {
return provisionResultMsg{err: fmt.Errorf("PLATFORM_PROVISION_URL is required for device registration.")}
}
device := buildProvisionDevice(deviceHostname, workspaceRoot, usageType)
payload := map[string]any{
"account": account,
"userCode": registrationCode,
"hostname": device["hostname"],
"os": device["os"],
"arch": device["arch"],
"kernel": device["kernel"],
"distro": device["distro"],
"usageType": usageType,
"sshUser": device["sshUser"],
"workspaceRoot": workspaceRoot,
"device": device,
}
body, status, err := postProvision(baseURL+"/device-authorizations/consume", payload)
if err != nil {
return provisionResultMsg{err: fmt.Errorf("Device registration failed: %w", err)}
}
if status < 200 || status >= 300 {
return provisionResultMsg{err: fmt.Errorf("Device registration failed: HTTP %d: %s", status, strings.TrimSpace(string(body)))}
}
values, err := parseProvisionValues(body)
if err != nil {
return provisionResultMsg{err: err}
}
return provisionResultMsg{values: values, summary: renderProvisionSummary(values, device, usageType, "Registered", width)}
}
}
func fetchProvision(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) (map[string]string, string, error) {
account = strings.TrimSpace(account)
password = strings.TrimSpace(password)
usageType = normalizeUsageType(usageType)
deviceHostname = strings.TrimSpace(deviceHostname)
workspaceRoot = strings.TrimSpace(workspaceRoot)
if workspaceRoot == "" {
workspaceRoot = "~/autonomy-studio/workspaces"
}
if account == "" || password == "" {
return nil, "", fmt.Errorf("Autonomy Studio account and password are required.")
}
url := strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision"))
if url == "" {
return nil, "", fmt.Errorf("PLATFORM_PROVISION_URL is required for account provisioning.")
}
device := detectDevice()
if deviceHostname != "" {
device["hostname"] = deviceHostname
}
sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", ""))
if sshUser != "" {
device["sshUser"] = sshUser
}
device["workspaceRoot"] = workspaceRoot
payload := map[string]any{
"account": account,
"password": password,
"hostname": device["hostname"],
"os": device["os"],
"arch": device["arch"],
"kernel": device["kernel"],
"distro": device["distro"],
"device": device,
"usageType": usageType,
"sshUser": sshUser,
"workspaceRoot": workspaceRoot,
}
device["usageType"] = usageType
respBody, status, err := postProvision(url, payload)
if err != nil {
return nil, "", fmt.Errorf("Provision request failed: %w", err)
}
if status == http.StatusBadRequest {
legacyPayload := map[string]any{
"account": account,
"password": password,
"hostname": device["hostname"],
"usageType": usageType,
"sshUser": sshUser,
"workspaceRoot": workspaceRoot,
}
respBody, status, err = postProvision(url, legacyPayload)
if err != nil {
return nil, "", fmt.Errorf("Provision retry failed: %w", err)
}
}
if status < 200 || status >= 300 {
detail := strings.TrimSpace(string(respBody))
if detail != "" {
return nil, "", fmt.Errorf("Provision request failed: HTTP %d: %s", status, detail)
}
return nil, "", fmt.Errorf("Provision request failed: HTTP %d", status)
}
values, err := parseProvisionValues(respBody)
if err != nil {
return nil, "", err
}
return values, renderProvisionSummary(values, device, usageType, "Received", width), nil
}
func parseProvisionValues(respBody []byte) (map[string]string, error) {
var data map[string]any
if err := json.Unmarshal(respBody, &data); err != nil {
return nil, fmt.Errorf("Provision response must be JSON.")
}
values := map[string]string{}
setProvision(values, "INSTALL_TAILSCALE", firstBool(data, "installTailscale", "install_tailscale", "tailscaleEnabled", "tailscale_enabled"))
setProvision(values, "TAILSCALE_AUTH_KEY", firstString(data, "tailscaleAuthKey", "tailscale_auth_key", "authKey", "auth_key"))
setProvision(values, "TAILSCALE_LOGIN_SERVER", firstString(data, "tailscaleLoginServer", "tailscale_login_server", "loginServer", "login_server"))
setProvision(values, "TAILSCALE_HOSTNAME", firstString(data, "tailscaleHostname", "tailscale_hostname", "hostname"))
setProvision(values, "TAILSCALE_EXTRA_ARGS", firstString(data, "tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args"))
setProvision(values, "AGENT_GATEWAY_TOKEN", firstString(data, "agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token"))
if values["INSTALL_TAILSCALE"] == "" && values["TAILSCALE_AUTH_KEY"] != "" {
values["INSTALL_TAILSCALE"] = "true"
}
return values, nil
}
func normalizeUsageType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "교보재용", "use for class", "class", "education", "educational", "training", "classroom":
return "class"
default:
return "personal"
}
}
func postProvision(url string, payload map[string]any) ([]byte, int, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return respBody, resp.StatusCode, nil
}
func detectDevice() map[string]string {
hostname, _ := os.Hostname()
device := map[string]string{
"hostname": hostname,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"kernel": strings.TrimSpace(runOutput("uname", "-r")),
"distro": osReleasePrettyName(),
}
if device["kernel"] == "" {
device["kernel"] = "-"
}
if device["distro"] == "" {
device["distro"] = runtime.GOOS
}
return device
}
func runOutput(name string, args ...string) string {
output, err := exec.Command(name, args...).Output()
if err != nil {
return ""
}
return string(output)
}
func osReleasePrettyName() string {
content, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
for _, line := range strings.Split(string(content), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok || key != "PRETTY_NAME" {
continue
}
value = strings.Trim(value, `"`)
return strings.TrimSpace(value)
}
return ""
}
func setProvision(values map[string]string, key string, value string) {
if strings.TrimSpace(value) != "" {
values[key] = strings.TrimSpace(value)
}
}
func firstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key]; ok {
switch v := value.(type) {
case string:
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
return "true"
}
return "false"
}
}
}
return ""
}
func firstBool(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key]; ok {
switch v := value.(type) {
case bool:
if v {
return "true"
}
return "false"
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "y", "on":
return "true"
case "false", "0", "no", "n", "off":
return "false"
}
}
}
}
return ""
}
func renderProvisionSummary(values map[string]string, device map[string]string, usageType string, status string, width int) string {
authKeyStatus := "not provided"
if values["TAILSCALE_AUTH_KEY"] != "" {
authKeyStatus = "received"
}
loginServer := displayValue(valueOrDash(values["TAILSCALE_LOGIN_SERVER"]))
hostname := valueOrDash(values["TAILSCALE_HOSTNAME"])
extraArgs := valueOrDash(values["TAILSCALE_EXTRA_ARGS"])
gatewayTokenStatus := "not provided"
if values["AGENT_GATEWAY_TOKEN"] != "" {
gatewayTokenStatus = "received"
}
installTailscale := valueOr(values["INSTALL_TAILSCALE"], "false")
header := renderMarkdown(fmt.Sprintf(`# Provisioning Summary
Status: **%s**
## Network
`, status), width)
rows := [][2]string{
{"Usage type", normalizeUsageType(usageType)},
{"Device hostname", valueOrDash(device["hostname"])},
{"Device OS", valueOrDash(device["distro"])},
{"Architecture", valueOrDash(device["arch"])},
{"Kernel", valueOrDash(device["kernel"])},
{"Install Tailscale", installTailscale},
{"Tailscale auth key", authKeyStatus},
{"Login server", loginServer},
{"Tailscale hostname", hostname},
{"Extra args", extraArgs},
{"Agent gateway token", gatewayTokenStatus},
}
return strings.TrimSpace(header + "\n" + renderSummaryTable(rows, width) + "\n\n" + mutedStyle.Render("These values will be applied when installation starts."))
}
func renderMarkdown(markdown string, width int) string {
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(max(40, width-14)),
)
if err != nil {
return markdown
}
rendered, err := renderer.Render(markdown)
if err != nil {
return markdown
}
return strings.TrimSpace(rendered)
}
func renderSummaryTable(rows [][2]string, width int) string {
tableWidth := min(max(56, width-18), 82)
labelWidth := 22
valueWidth := max(18, tableWidth-labelWidth-7)
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(accent)
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("229"))
borderStyle := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(0, 1)
lines := []string{
headerStyle.Width(labelWidth).Render("Setting") + " " + headerStyle.Width(valueWidth).Render("Value"),
mutedStyle.Render(strings.Repeat("─", labelWidth) + " " + strings.Repeat("─", valueWidth)),
}
for _, row := range rows {
lines = append(lines,
keyStyle.Width(labelWidth).Render(row[0])+" "+
valueStyle.Width(valueWidth).Render(clip(row[1], valueWidth)),
)
}
return borderStyle.Width(tableWidth).Render(strings.Join(lines, "\n"))
}
func clip(value string, width int) string {
if width < 4 || len(value) <= width {
return value
}
return value[:width-3] + "..."
}
func valueOr(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func valueOrDash(value string) string {
return valueOr(value, "-")
}
func displayValue(value string) string {
return strings.ReplaceAll(value, "://", "[:]//")
}
func (m model) writeEnv() error {
lines := make([]string, 0, len(questions))
defaults := map[string]string{
"PLATFORM_AGENT_DIR": envDefault("PLATFORM_AGENT_DIR", "/opt/platform-agent"),
"PLATFORM_AGENT_SERVICE": envDefault("PLATFORM_AGENT_SERVICE", "platform-agent"),
"PLATFORM_AGENT_USER": envDefault("PLATFORM_AGENT_USER", envDefault("USER", "ubuntu")),
"PLATFORM_AGENT_REPO_URL": envDefault("PLATFORM_AGENT_REPO_URL", ""),
"ROBOT_STACK": envDefault("ROBOT_STACK", "autoware"),
"ROS_DOMAIN_ID": envDefault("ROS_DOMAIN_ID", "0"),
"CAMERA_ENABLED": envDefault("CAMERA_ENABLED", "true"),
"CAMERA_SIGNALING_URL": envDefault("CAMERA_SIGNALING_URL", "ws://127.0.0.1:3000"),
"KASMVNC_GEOMETRY": envDefault("KASMVNC_GEOMETRY", "1600x900"),
}
for key, value := range defaults {
lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value)))
}
for key, value := range m.provision {
lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value)))
}
for _, q := range questions {
if q.key == "PLATFORM_ACCOUNT" || q.key == "PLATFORM_REGISTRATION_CODE" || q.key == "PLATFORM_PASSWORD" {
continue
}
value := strings.TrimSpace(m.values[q.key])
if value == "" {
value = questionDefault(q)
}
lines = append(lines, fmt.Sprintf("%s=%s", q.key, shellQuote(value)))
}
return os.WriteFile(m.envPath, []byte(strings.Join(lines, "\n")+"\n"), 0600)
}
func (m model) View() string {
contentWidth := min(max(64, m.width-8), 92)
innerWidth := contentWidth - 6
var b strings.Builder
b.WriteString(header(m.index, len(questions), innerWidth) + "\n\n")
if m.loading {
b.WriteString(statusPanel("Registering platform", "Validating the account and registration code.", innerWidth) + "\n\n")
b.WriteString(mutedStyle.Render("Please keep this terminal open. Esc cancels setup."))
return renderShell(b.String(), contentWidth)
}
if m.showProvision {
b.WriteString(okStyle.Render("Connection settings received") + "\n")
b.WriteString(mutedStyle.Render("Review the detected device and network settings before continuing.") + "\n\n")
b.WriteString(m.provisionSummary + "\n")
if m.err != nil {
b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n")
}
b.WriteString("\n" + footerHint("Enter", "continue", "←", "previous", "Esc", "cancel"))
return renderShell(b.String(), contentWidth)
}
q := questions[m.index]
if answers := recentAnswers(m); len(answers) > 0 {
b.WriteString(answerSummary(answers, innerWidth) + "\n\n")
}
b.WriteString(sectionLabel(stepName(m.index)) + "\n")
b.WriteString(promptStyle.Render(q.title) + "\n")
b.WriteString(mutedStyle.Render(wrap(q.prompt, innerWidth)) + "\n")
if example := questionExample(q); example != "" {
b.WriteString(keyStyle.Render("Example ") + mutedStyle.Render(example) + "\n")
}
b.WriteString("\n")
if len(q.options) > 0 {
b.WriteString(renderOptions(q.options, m.choices[q.key], innerWidth) + "\n")
} else {
b.WriteString(inputStyle.Width(innerWidth-2).Render(m.input.View()) + "\n")
}
if m.err != nil {
b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n")
}
if len(q.options) > 0 {
b.WriteString("\n" + footerHint("↑/↓", "select", "Enter", nextAction(m.index), "←", "previous", "Esc", "cancel"))
} else {
b.WriteString("\n" + footerHint("Enter", nextAction(m.index), "←", "previous", "Esc", "cancel"))
}
return renderShell(b.String(), contentWidth)
}
func renderOptions(options []string, selected int, width int) string {
lines := make([]string, len(options))
for i, option := range options {
if i == selected {
lines[i] = selectedStyle.Width(width - 2).Render("● " + option)
} else {
lines[i] = optionStyle.Width(width - 2).Render("○ " + option)
}
}
return strings.Join(lines, "\n\n")
}
func header(index, total, width int) string {
steps := []string{"Device", "Connection", "Services"}
current := 0
switch {
case index >= 3:
current = 2
case index >= 2:
current = 1
}
parts := make([]string, 0, len(steps))
for i, step := range steps {
label := fmt.Sprintf(" %d %s ", i+1, step)
switch {
case i < current:
parts = append(parts, lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓"+label))
case i == current:
parts = append(parts, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Render(label))
default:
parts = append(parts, mutedStyle.Render(label))
}
}
return brandStyle.Render("AUTONOMY STUDIO") + "\n" +
titleStyle.Render("Platform Agent Setup") + "\n\n" +
strings.Join(parts, mutedStyle.Render(" ─ "))
}
func recentAnswers(m model) []string {
start := max(0, m.index-2)
lines := []string{}
for i := start; i < m.index; i++ {
q := questions[i]
value := m.values[q.key]
if q.secret && value != "" {
value = "********"
}
if value == "" {
value = questionDefault(q)
}
lines = append(lines, q.title+"\t"+value)
}
return lines
}
func renderShell(content string, width int) string {
return lipgloss.NewStyle().Margin(1, 2).Render(panelStyle.Width(width).Render(content))
}
func sectionLabel(value string) string {
return lipgloss.NewStyle().Bold(true).Foreground(accent).Render(strings.ToUpper(value))
}
func stepName(index int) string {
switch {
case index < 2:
return "Device"
case index == 2:
return "Connection"
default:
return "Services"
}
}
func nextAction(index int) string {
if index == len(questions)-1 {
return "install"
}
return "next"
}
func footerHint(parts ...string) string {
items := make([]string, 0, len(parts)/2)
for i := 0; i+1 < len(parts); i += 2 {
items = append(items, lipgloss.NewStyle().Bold(true).Foreground(accent).Render(parts[i])+mutedStyle.Render(" "+parts[i+1]))
}
return strings.Join(items, mutedStyle.Render(" · "))
}
func answerSummary(answers []string, width int) string {
rows := make([]string, 0, len(answers))
for _, answer := range answers {
key, value, ok := strings.Cut(answer, "\t")
if ok {
rows = append(rows, mutedStyle.Width(22).Render(key)+valueStyle.Render(clip(value, max(18, width-27))))
}
}
if len(rows) == 0 {
return ""
}
return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(panelBorder).Padding(0, 1).Render(strings.Join(rows, "\n"))
}
func statusPanel(title, description string, width int) string {
spinner := lipgloss.NewStyle().Bold(true).Foreground(accentStrong).Render("◌")
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 2).Width(width - 2).Render(
spinner + " " + promptStyle.Render(title) + "\n" + mutedStyle.Render(wrap(description, width-10)),
)
}
func wrap(text string, width int) string {
return lipgloss.NewStyle().Width(width).Render(text)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: installer-tui <env-output-path>")
os.Exit(2)
}
program := tea.NewProgram(initialModel(os.Args[1]), tea.WithAltScreen())
finalModel, err := program.Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if m, ok := finalModel.(model); ok && !m.done {
os.Exit(130)
}
}
+180
View File
@@ -0,0 +1,180 @@
import asyncio
import json
import os
import signal
import websockets
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstWebRTC", "1.0")
gi.require_version("GstSdp", "1.0")
from gi.repository import Gst, GstWebRTC, GstSdp
SIGNALING_URL = os.environ.get("SIGNALING_URL") or os.environ.get("CAMERA_SIGNALING_URL", "ws://127.0.0.1:3000")
ROOM_ID = os.environ.get("ROOM_ID", "jetson-1")
VIDEO_DEVICE = os.environ.get("VIDEO_DEVICE", "/dev/video0")
VIDEO_WIDTH = int(os.environ.get("VIDEO_WIDTH", "1920"))
VIDEO_HEIGHT = int(os.environ.get("VIDEO_HEIGHT", "1080"))
VIDEO_FRAMERATE = int(os.environ.get("VIDEO_FRAMERATE", "30"))
VIDEO_BITRATE = int(os.environ.get("VIDEO_BITRATE", "4000000"))
def default_pipeline_desc():
return f"""
v4l2src device={VIDEO_DEVICE} !
video/x-raw,format=UYVY,width={VIDEO_WIDTH},height={VIDEO_HEIGHT},framerate={VIDEO_FRAMERATE}/1 !
nvvidconv !
video/x-raw(memory:NVMM),format=NV12 !
nvv4l2h264enc bitrate={VIDEO_BITRATE} insert-sps-pps=true iframeinterval=30 !
h264parse !
rtph264pay config-interval=1 pt=96 !
application/x-rtp,media=video,encoding-name=H264,payload=96 !
webrtcbin name=webrtc bundle-policy=max-bundle
"""
PIPELINE_DESC = os.environ.get("PIPELINE_DESC") or default_pipeline_desc()
ws = None
loop = None
pipeline = None
webrtc = None
stopping = False
def log(message):
print(message, flush=True)
def send_json(data):
if ws and loop and not stopping:
asyncio.run_coroutine_threadsafe(ws.send(json.dumps(data)), loop)
def on_offer_created(promise, _, __):
promise.wait()
reply = promise.get_reply()
offer = reply.get_value("offer")
webrtc.emit("set-local-description", offer, Gst.Promise.new())
send_json({
"type": "offer",
"offer": {
"type": "offer",
"sdp": offer.sdp.as_text(),
},
})
log("offer sent")
def on_negotiation_needed(element):
promise = Gst.Promise.new_with_change_func(on_offer_created, None, None)
element.emit("create-offer", None, promise)
def request_offer(reason):
if not webrtc or stopping:
return
log(f"creating offer: {reason}")
on_negotiation_needed(webrtc)
def on_ice_candidate(element, mlineindex, candidate):
send_json({
"type": "candidate",
"candidate": {
"candidate": candidate,
"sdpMLineIndex": mlineindex,
},
})
async def handle_message(message):
data = json.loads(message)
if data["type"] == "joined" and data.get("role") == "viewer":
request_offer("viewer joined")
elif data["type"] == "answer":
sdp = data["answer"]["sdp"]
_, sdpmsg = GstSdp.SDPMessage.new()
GstSdp.sdp_message_parse_buffer(bytes(sdp.encode()), sdpmsg)
answer = GstWebRTC.WebRTCSessionDescription.new(
GstWebRTC.WebRTCSDPType.ANSWER,
sdpmsg,
)
webrtc.emit("set-remote-description", answer, Gst.Promise.new())
log("answer applied")
elif data["type"] == "candidate":
candidate = data["candidate"]
webrtc.emit(
"add-ice-candidate",
candidate.get("sdpMLineIndex", 0),
candidate["candidate"],
)
async def main():
global ws, loop, pipeline, webrtc
loop = asyncio.get_running_loop()
log(f"connecting signaling {SIGNALING_URL}, room={ROOM_ID}")
log("pipeline:")
log(PIPELINE_DESC)
pipeline = Gst.parse_launch(PIPELINE_DESC)
webrtc = pipeline.get_by_name("webrtc")
if not webrtc:
raise RuntimeError("webrtcbin named 'webrtc' was not found in pipeline.")
webrtc.connect("on-negotiation-needed", on_negotiation_needed)
webrtc.connect("on-ice-candidate", on_ice_candidate)
async with websockets.connect(SIGNALING_URL) as websocket:
ws = websocket
await ws.send(json.dumps({
"type": "join",
"roomId": ROOM_ID,
"role": "jetson",
}))
pipeline.set_state(Gst.State.PLAYING)
log("pipeline started")
await asyncio.sleep(0.5)
request_offer("pipeline started")
async for message in ws:
await handle_message(message)
def stop_pipeline():
global stopping
stopping = True
if pipeline:
pipeline.set_state(Gst.State.NULL)
log("pipeline stopped")
def handle_stop_signal(signum, frame):
stop_pipeline()
raise SystemExit(0)
if __name__ == "__main__":
Gst.init(None)
signal.signal(signal.SIGTERM, handle_stop_signal)
signal.signal(signal.SIGINT, handle_stop_signal)
try:
asyncio.run(main())
finally:
stop_pipeline()
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env sh
set -eu
INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}"
SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}"
CONFIG_PATH="${AGENT_CONFIG:-${INSTALL_DIR}/agent.config.json}"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
log() {
printf '%s\n' "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif need_cmd sudo; then
sudo "$@"
else
log "Root privileges are required: $*"
exit 1
fi
}
prompt_value() {
var_name="$1"
label="$2"
current_value="$3"
secret="${4:-false}"
if [ "$secret" = "true" ]; then
printf '%s [hidden, Enter keeps current]: ' "$label"
stty -echo 2>/dev/null || true
IFS= read -r answer || answer=""
stty echo 2>/dev/null || true
printf '\n'
else
printf '%s [%s]: ' "$label" "$current_value"
IFS= read -r answer || answer=""
fi
if [ -n "$answer" ]; then
eval "$var_name=\"\$answer\""
fi
}
prompt_yes_no() {
label="$1"
default="${2:-no}"
suffix="[y/N]"
[ "$default" = "yes" ] && suffix="[Y/n]"
printf '%s %s ' "$label" "$suffix"
IFS= read -r answer || answer=""
answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')"
if [ -z "$answer" ]; then
[ "$default" = "yes" ]
return
fi
[ "$answer" = "y" ] || [ "$answer" = "yes" ]
}
json_get() {
key_path="$1"
fallback="${2:-}"
python3 - "$CONFIG_PATH" "$key_path" "$fallback" <<'PY'
import json
import sys
path, key_path, fallback = sys.argv[1], sys.argv[2], sys.argv[3]
try:
with open(path, "r", encoding="utf-8") as fp:
value = json.load(fp)
except Exception:
print(fallback)
raise SystemExit
for key in key_path.split("."):
if not isinstance(value, dict):
value = None
break
value = value.get(key)
if value is None:
print(fallback)
elif isinstance(value, bool):
print("true" if value else "false")
else:
print(value)
PY
}
service_user() {
if [ -f "$SERVICE_FILE" ]; then
user_line="$(sed -n 's/^User=//p' "$SERVICE_FILE" | head -n 1)"
[ -n "$user_line" ] && {
printf '%s' "$user_line"
return
}
fi
id -un
}
expand_path_for_user() {
path="$1"
run_user="$2"
case "$path" in
"~")
if [ "$run_user" = "root" ]; then printf '/root'; else printf '/home/%s' "$run_user"; fi
;;
"~/"*)
if [ "$run_user" = "root" ]; then printf '/root/%s' "${path#~/}"; else printf '/home/%s/%s' "$run_user" "${path#~/}"; fi
;;
*)
printf '%s' "$path"
;;
esac
}
write_config() {
tmp_file="$(mktemp)"
WORKSPACE_ROOT="$WORKSPACE_ROOT" \
ROBOT_STACK="$ROBOT_STACK" \
ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \
CAMERA_ENABLED="$CAMERA_ENABLED" \
CAMERA_VIDEO_DEVICE="$CAMERA_VIDEO_DEVICE" \
CAMERA_SIGNALING_URL="$CAMERA_SIGNALING_URL" \
KASMVNC_GEOMETRY="$KASMVNC_GEOMETRY" \
AGENT_GATEWAY_TOKEN="$AGENT_GATEWAY_TOKEN" \
python3 - "$CONFIG_PATH" "$tmp_file" <<'PY'
import json
import os
import sys
source, target = sys.argv[1], sys.argv[2]
with open(source, "r", encoding="utf-8") as fp:
cfg = json.load(fp)
cfg.setdefault("workspace", {})
cfg.setdefault("ros", {})
cfg.setdefault("camera", {})
cfg.setdefault("desktop", {})
cfg.setdefault("security", {})
cfg["workspace"]["root"] = os.environ["WORKSPACE_ROOT"]
cfg["ros"]["stack"] = "nav2" if os.environ["ROBOT_STACK"].lower() == "nav2" else "autoware"
cfg["ros"]["domainId"] = os.environ["ROS_DOMAIN_ID"] if os.environ["ROS_DOMAIN_ID"].isdigit() else "0"
cfg["camera"]["enabled"] = os.environ["CAMERA_ENABLED"].lower() in ("true", "1", "yes", "y", "on")
cfg["camera"]["videoDevice"] = os.environ["CAMERA_VIDEO_DEVICE"]
cfg["camera"]["signalingUrl"] = os.environ["CAMERA_SIGNALING_URL"]
cfg["desktop"]["geometry"] = os.environ["KASMVNC_GEOMETRY"]
if os.environ["AGENT_GATEWAY_TOKEN"]:
cfg["security"]["gatewayToken"] = os.environ["AGENT_GATEWAY_TOKEN"]
with open(target, "w", encoding="utf-8") as fp:
json.dump(cfg, fp, ensure_ascii=False, indent=2)
fp.write("\n")
PY
as_root cp "$tmp_file" "$CONFIG_PATH"
rm -f "$tmp_file"
}
restart_service() {
if need_cmd systemctl; then
as_root systemctl restart "$SERVICE_NAME"
elif need_cmd service; then
as_root service "$SERVICE_NAME" restart
else
log "No service manager found. Restart the agent manually."
fi
}
if ! need_cmd python3; then
log "python3 is required to edit ${CONFIG_PATH}."
exit 1
fi
if [ ! -f "$CONFIG_PATH" ]; then
log "Config not found: ${CONFIG_PATH}"
log "Set AGENT_CONFIG=/path/to/agent.config.json or install the agent first."
exit 1
fi
RUN_USER="$(service_user)"
WORKSPACE_ROOT="$(json_get workspace.root '~/autonomy-studio/workspaces')"
ROBOT_STACK="$(json_get ros.stack autoware)"
ROS_DOMAIN_ID="$(json_get ros.domainId 0)"
CAMERA_ENABLED="$(json_get camera.enabled false)"
CAMERA_VIDEO_DEVICE="$(json_get camera.videoDevice /dev/video0)"
CAMERA_SIGNALING_URL="$(json_get camera.signalingUrl ws://127.0.0.1:3000)"
KASMVNC_GEOMETRY="$(json_get desktop.geometry 1600x900)"
AGENT_GATEWAY_TOKEN="$(json_get security.gatewayToken '')"
while :; do
clear
log "Autonomy Studio Platform Agent Settings"
log "======================================="
log "Config : ${CONFIG_PATH}"
log "Service : ${SERVICE_NAME}"
log ""
log "1. Workspace root ${WORKSPACE_ROOT}"
log "2. ROS stack ${ROBOT_STACK}"
log "3. ROS_DOMAIN_ID ${ROS_DOMAIN_ID}"
log "4. Camera enabled ${CAMERA_ENABLED}"
log "5. Camera device ${CAMERA_VIDEO_DEVICE}"
log "6. Camera signaling ${CAMERA_SIGNALING_URL}"
log "7. KasmVNC resolution ${KASMVNC_GEOMETRY}"
log "8. Gateway token $(if [ -n "$AGENT_GATEWAY_TOKEN" ]; then printf 'configured'; else printf 'empty'; fi)"
log ""
log "s. Save and restart"
log "q. Quit"
log ""
printf 'Select: '
IFS= read -r choice || choice=""
case "$choice" in
1)
prompt_value WORKSPACE_ROOT "Workspace root" "$WORKSPACE_ROOT"
;;
2)
prompt_value ROBOT_STACK "ROS stack (autoware/nav2)" "$ROBOT_STACK"
case "$ROBOT_STACK" in
nav2|NAV2) ROBOT_STACK="nav2" ;;
*) ROBOT_STACK="autoware" ;;
esac
;;
3)
prompt_value ROS_DOMAIN_ID "ROS_DOMAIN_ID" "$ROS_DOMAIN_ID"
ROS_DOMAIN_ID="$(printf '%s' "$ROS_DOMAIN_ID" | tr -cd '0-9')"
[ -n "$ROS_DOMAIN_ID" ] || ROS_DOMAIN_ID="0"
;;
4)
prompt_value CAMERA_ENABLED "Camera enabled true/false" "$CAMERA_ENABLED"
;;
5)
prompt_value CAMERA_VIDEO_DEVICE "Camera device" "$CAMERA_VIDEO_DEVICE"
;;
6)
prompt_value CAMERA_SIGNALING_URL "Camera signaling URL" "$CAMERA_SIGNALING_URL"
;;
7)
prompt_value KASMVNC_GEOMETRY "KasmVNC resolution" "$KASMVNC_GEOMETRY"
;;
8)
prompt_value AGENT_GATEWAY_TOKEN "Gateway token" "$AGENT_GATEWAY_TOKEN" true
;;
s|S)
write_config
workspace_path="$(expand_path_for_user "$WORKSPACE_ROOT" "$RUN_USER")"
if prompt_yes_no "Create workspace directory ${workspace_path} if missing?" "yes"; then
as_root mkdir -p "$workspace_path"
as_root chown -R "$RUN_USER":"$RUN_USER" "$workspace_path" || true
fi
restart_service
log ""
log "Settings saved and service restarted."
exit 0
;;
q|Q)
log "Canceled."
exit 0
;;
esac
done
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Platform Agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/platform-agent
ExecStart=/home/ubuntu/.bun/bin/bun src/server.tsx
Restart=always
RestartSec=2
Environment=HOST=0.0.0.0
Environment=AGENT_CONFIG=/opt/platform-agent/agent.config.json
[Install]
WantedBy=multi-user.target
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env sh
set -eu
INSTALL_DIR="${PLATFORM_AGENT_DIR:-/opt/platform-agent}"
SERVICE_NAME="${PLATFORM_AGENT_SERVICE:-platform-agent}"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
log() {
printf '%s\n' "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif need_cmd sudo; then
sudo "$@"
else
log "Root privileges are required: $*"
exit 1
fi
}
prompt_yes_no() {
label="$1"
default="${2:-no}"
suffix="[y/N]"
[ "$default" = "yes" ] && suffix="[Y/n]"
printf '%s %s ' "$label" "$suffix"
IFS= read -r answer || answer=""
answer="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')"
if [ -z "$answer" ]; then
[ "$default" = "yes" ]
return
fi
[ "$answer" = "y" ] || [ "$answer" = "yes" ]
}
read_config_value() {
key_path="$1"
config_path="${INSTALL_DIR}/agent.config.json"
if [ ! -f "$config_path" ] || ! need_cmd python3; then
return
fi
python3 - "$config_path" "$key_path" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fp:
data = json.load(fp)
value = data
for key in sys.argv[2].split("."):
if not isinstance(value, dict):
value = None
break
value = value.get(key)
if isinstance(value, str):
print(value)
PY
}
expand_path_for_user() {
path="$1"
run_user="$2"
case "$path" in
"~")
if [ "$run_user" = "root" ]; then
printf '/root'
else
printf '/home/%s' "$run_user"
fi
;;
"~/"*)
if [ "$run_user" = "root" ]; then
printf '/root/%s' "${path#~/}"
else
printf '/home/%s/%s' "$run_user" "${path#~/}"
fi
;;
*)
printf '%s' "$path"
;;
esac
}
service_user() {
if [ -f "$SERVICE_FILE" ]; then
user_line="$(sed -n 's/^User=//p' "$SERVICE_FILE" | head -n 1)"
[ -n "$user_line" ] && {
printf '%s' "$user_line"
return
}
fi
id -un
}
remove_packages() {
packages="$1"
if [ -z "$packages" ]; then
return
fi
if need_cmd apt-get; then
as_root apt-get remove -y $packages || true
as_root apt-get autoremove -y || true
elif need_cmd dnf; then
as_root dnf remove -y $packages || true
elif need_cmd yum; then
as_root yum remove -y $packages || true
elif need_cmd pacman; then
as_root pacman -Rns --noconfirm $packages || true
elif need_cmd zypper; then
as_root zypper --non-interactive remove $packages || true
else
log "No supported package manager found. Skipping package removal."
fi
}
clear
log "Autonomy Studio Platform Agent Uninstall"
log "========================================"
log "Service : ${SERVICE_NAME}"
log "Install dir : ${INSTALL_DIR}"
log ""
RUN_USER="$(service_user)"
WORKSPACE_ROOT="$(read_config_value workspace.root || true)"
[ -n "${WORKSPACE_ROOT:-}" ] || WORKSPACE_ROOT="~/autonomy-studio/workspaces"
WORKSPACE_PATH="$(expand_path_for_user "$WORKSPACE_ROOT" "$RUN_USER")"
log "Detected user : ${RUN_USER}"
log "Workspace root : ${WORKSPACE_ROOT}"
log "Resolved workspace : ${WORKSPACE_PATH}"
log ""
if ! prompt_yes_no "Uninstall the platform agent now?" "no"; then
log "Canceled."
exit 0
fi
if need_cmd systemctl; then
log "Stopping systemd service."
as_root systemctl stop "$SERVICE_NAME" 2>/dev/null || true
as_root systemctl disable "$SERVICE_NAME" 2>/dev/null || true
if [ -f "$SERVICE_FILE" ]; then
as_root rm -f "$SERVICE_FILE"
as_root systemctl daemon-reload
fi
elif need_cmd service; then
as_root service "$SERVICE_NAME" stop 2>/dev/null || true
fi
if prompt_yes_no "Remove install directory ${INSTALL_DIR}?" "yes"; then
case "$INSTALL_DIR" in
"/"|"/root"|"/home"|"/usr"|"/opt"|"/var"|"/etc")
log "Refusing to remove broad system directory: ${INSTALL_DIR}"
;;
*)
as_root rm -rf "$INSTALL_DIR"
;;
esac
fi
if prompt_yes_no "Delete workspace ${WORKSPACE_PATH}? This removes cloned modules and user files." "no"; then
case "$WORKSPACE_PATH" in
"/home/$RUN_USER"|"/home/$RUN_USER/")
log "Refusing to remove user home directory: ${WORKSPACE_PATH}"
;;
"/"|"/root"|"/home"|"/usr"|"/opt"|"/var"|"/etc"|"/home/"*)
as_root rm -rf "$WORKSPACE_PATH"
;;
*)
as_root rm -rf "$WORKSPACE_PATH"
;;
esac
fi
if need_cmd tailscale && prompt_yes_no "Logout this device from Tailscale/Headscale?" "no"; then
as_root tailscale logout || true
fi
if prompt_yes_no "Remove packages installed by install.sh if present? (tailscale openssh-server git)" "no"; then
remove_packages "tailscale openssh-server git"
fi
log ""
log "Uninstall complete."
+260
View File
@@ -0,0 +1,260 @@
import { Hono } from "hono";
import type { AgentConfig } from "./config.ts";
type ThreePose = {
x: number;
y: number;
z: number;
heading: number;
};
type MapCenter = {
x: number;
y: number;
};
type MapPose = {
x: number;
y: number;
z: number;
qx: number;
qy: number;
qz: number;
qw: number;
};
function bridgeUrl(config: AgentConfig): string {
return config.ros.bridgeUrl || `http://${config.ros.bridgeHost}:${config.ros.bridgePort}`;
}
function defaultCenter(): MapCenter {
return {
x: Number(process.env.CENTER_X ?? "3763.558837890625"),
y: Number(process.env.CENTER_Y ?? "73675.99609375"),
};
}
function validCenter(center?: MapCenter): MapCenter | undefined {
return center && Number.isFinite(center.x) && Number.isFinite(center.y) ? center : undefined;
}
function threeToMap(pose: ThreePose, center = defaultCenter()): MapPose {
const headingRad = (pose.heading * Math.PI) / 180;
const yaw = Math.PI / 2 - headingRad;
return {
x: pose.x + center.x,
y: center.y - pose.z,
z: pose.y,
qx: 0,
qy: 0,
qz: Math.sin(yaw / 2),
qw: Math.cos(yaw / 2),
};
}
function mapToThree(mx: number, my: number, mz: number, center = defaultCenter()) {
return {
x: mx - center.x,
y: mz,
z: -(my - center.y),
};
}
function sampleTrajectory(points: number[][], minDist = 0.5): number[][] {
if (points.length === 0) return [];
const result: number[][] = [points[0]];
let lx = points[0][0];
let ly = points[0][1];
for (const point of points.slice(1)) {
const dx = point[0] - lx;
const dy = point[1] - ly;
if (Math.sqrt(dx * dx + dy * dy) >= minDist) {
result.push(point.map((value, index) => index <= 3 ? Number(value.toFixed(3)) : value));
lx = point[0];
ly = point[1];
}
}
return result;
}
function trimBehindStart(points: [number, number, number, number][], start: ThreePose) {
const headingRad = (start.heading * Math.PI) / 180;
const fwdX = Math.sin(headingRad);
const fwdZ = -Math.cos(headingRad);
return points.filter((point) => {
const dx = point[0] - start.x;
const dz = point[2] - start.z;
return dx * fwdX + dz * fwdZ >= -0.5;
});
}
function convertPoints(points: { x: number; y: number; z: number; t: number }[], center?: MapCenter) {
return points.map((point) => {
const three = mapToThree(point.x, point.y, point.z, center);
return [three.x, three.y, three.z, point.t] as [number, number, number, number];
});
}
async function bridgeFetch(config: AgentConfig, path: string, init?: RequestInit): Promise<Response> {
return fetch(`${bridgeUrl(config)}${path}`, init);
}
async function bridgeJson<T>(config: AgentConfig, path: string, init?: RequestInit): Promise<T> {
const response = await bridgeFetch(config, path, init);
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<T>;
}
function errorJson(error: unknown, fallback: string, status = 502): Response {
return Response.json({
ok: false,
error: fallback,
detail: error instanceof Error ? error.message : String(error),
}, { status });
}
export function createBridgeApi(config: AgentConfig): Hono {
const app = new Hono();
app.get("/status", async (c) => {
try {
const [health, stack] = await Promise.all([
bridgeJson(config, "/health").catch((error) => ({ ok: false, error: String(error) })),
bridgeJson(config, "/stack").catch((error) => ({ ok: false, error: String(error) })),
]);
return c.json({
ok: true,
configured: config.ros,
bridgeUrl: bridgeUrl(config),
health,
stack,
});
} catch (error) {
return c.json({ ok: false, bridgeUrl: bridgeUrl(config), message: error instanceof Error ? error.message : String(error) }, 502);
}
});
app.get("/stack", async (c) => {
try {
return c.json(await bridgeJson(config, "/stack"));
} catch (error) {
return errorJson(error, "Failed to read bridge stack.");
}
});
app.post("/clear", async (c) => {
try {
return c.json(await bridgeJson(config, "/clear_route", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to clear route.");
}
});
app.post("/stop", async (c) => {
try {
return c.json(await bridgeJson(config, "/disengage", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to stop robot.");
}
});
app.post("/reset", async (c) => {
try {
return c.json(await bridgeJson(config, "/reset", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to reset robot.");
}
});
return app;
}
export function createViewerControlApi(config: AgentConfig): Hono {
const app = new Hono();
app.post("/plan", async (c) => {
try {
const body = await c.req.json<{ start?: ThreePose; goal?: ThreePose; center?: MapCenter }>();
if (!body.goal) return c.json({ error: "goal is required" }, 400);
const center = validCenter(body.center);
const goalMap = threeToMap(body.goal, center);
const startMap = body.start ? threeToMap(body.start, center) : null;
if (startMap) {
await bridgeJson(config, "/set_initial_pose", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(startMap),
});
await Bun.sleep(800);
}
await bridgeJson(config, "/set_route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(goalMap),
});
const [pathData, trajData] = await Promise.all([
bridgeJson<{ points: { x: number; y: number; z: number; t: number }[] }>(config, "/get_behavior_path"),
bridgeJson<{ points: { x: number; y: number; z: number; t: number }[] }>(config, "/get_trajectory"),
]);
const planned = sampleTrajectory(convertPoints(pathData.points, center), 0.5);
const rawTrajectoryPoints = convertPoints(trajData.points, center);
const visibleTrajectoryPoints = body.start ? trimBehindStart(rawTrajectoryPoints, body.start) : rawTrajectoryPoints;
return c.json({
planned,
plannedTrajectory: sampleTrajectory(visibleTrajectoryPoints, 0.5),
});
} catch (error) {
return errorJson(error, "Failed to plan.");
}
});
app.post("/drive", async (c) => {
try {
const body = await c.req.json<{ goal?: ThreePose }>();
if (!body.goal) return c.json({ error: "goal is required" }, 400);
await bridgeJson(config, "/engage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ engage: true }),
});
return c.json({ ok: true });
} catch (error) {
return errorJson(error, "Failed to engage.");
}
});
app.post("/clear", async (c) => {
try {
return c.json(await bridgeJson(config, "/clear_route", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to clear route.");
}
});
app.post("/stop", async (c) => {
try {
return c.json(await bridgeJson(config, "/disengage", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to stop robot.");
}
});
app.post("/reset", async (c) => {
try {
return c.json(await bridgeJson(config, "/reset", { method: "POST" }));
} catch (error) {
return errorJson(error, "Failed to reset robot.");
}
});
return app;
}
+246
View File
@@ -0,0 +1,246 @@
import type { AgentConfig } from "./config.ts";
import { readdir, stat } from "node:fs/promises";
type CameraDevice = {
index: number;
path: string;
name: string;
available: boolean;
};
type CameraProcess = {
name: "sender";
proc: ReturnType<typeof Bun.spawn>;
command: string[];
env: CameraStartOptions;
startedAt: string;
exitedAt?: string;
exitCode?: number | null;
};
export type CameraStartOptions = {
signalingUrl?: string;
roomId?: string;
streamName?: string;
videoDevice?: string;
width?: number;
height?: number;
framerate?: number;
bitrate?: number;
pipelineDesc?: string;
};
export class CameraManager {
private sender?: CameraProcess;
private logs: string[] = [];
constructor(private readonly config: AgentConfig) {}
start(options: CameraStartOptions = {}) {
if (!this.config.camera.enabled) {
return { ok: false, error: "Camera streaming is disabled in agent.config.json." };
}
const startOptions = this.normalizeStartOptions(options);
if (this.sender && !this.hasExited(this.sender) && !this.sameStartOptions(this.sender.env, startOptions)) {
this.killProcess(this.sender);
this.sender = undefined;
}
if (!this.sender || this.hasExited(this.sender)) {
this.sender = this.spawnProcess(
"sender",
this.config.camera.senderCommand,
this.renderArgs(this.config.camera.senderArgs, startOptions),
startOptions,
);
}
return { ok: true, status: this.status() };
}
stop() {
this.killProcess(this.sender);
return { ok: true, status: this.status() };
}
status() {
return {
enabled: this.config.camera.enabled,
streamName: this.config.camera.streamName,
signalingUrl: this.config.camera.signalingUrl,
roomId: this.config.camera.roomId,
videoDevice: this.config.camera.videoDevice,
sender: this.processStatus(this.sender),
logs: this.logs.slice(-80),
};
}
async devices() {
const devices = await this.scanVideoDevices();
return { ok: true, devices };
}
private async scanVideoDevices(): Promise<CameraDevice[]> {
const entries = await readdir("/dev").catch(() => []);
const videoEntries = entries
.map((name) => {
const match = /^video(\d+)$/.exec(name);
if (!match) return null;
return { index: Number(match[1]), path: `/dev/${name}` };
})
.filter((entry) => entry !== null)
.sort((a, b) => a.index - b.index);
const devices = await Promise.all(videoEntries.map(async (entry) => {
const available = await stat(entry.path).then((fileStat) => fileStat.isCharacterDevice()).catch(() => false);
const name = await this.readVideoDeviceName(entry.path);
return {
...entry,
name: name || `Video ${entry.index}`,
available,
};
}));
return devices;
}
private async readVideoDeviceName(path: string): Promise<string> {
try {
const proc = Bun.spawn(["v4l2-ctl", "--device", path, "--info"], {
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) return "";
const cardLine = output.split(/\r?\n/).find((line) => line.trim().startsWith("Card type"));
return cardLine?.split(":").slice(1).join(":").trim() ?? "";
} catch {
return "";
}
}
private normalizeStartOptions(options: CameraStartOptions): Required<CameraStartOptions> {
return {
signalingUrl: options.signalingUrl || this.config.camera.signalingUrl,
roomId: options.roomId || this.config.camera.roomId,
streamName: options.streamName || this.config.camera.streamName,
videoDevice: options.videoDevice || this.config.camera.videoDevice,
width: Number(options.width || this.config.camera.width),
height: Number(options.height || this.config.camera.height),
framerate: Number(options.framerate || this.config.camera.framerate),
bitrate: Number(options.bitrate || this.config.camera.bitrate),
pipelineDesc: options.pipelineDesc ?? this.config.camera.pipelineDesc,
};
}
private sameStartOptions(current: CameraStartOptions, next: CameraStartOptions) {
return JSON.stringify(current) === JSON.stringify(next);
}
private renderArgs(args: string[], options: Required<CameraStartOptions>) {
return args.map((arg) =>
arg
.replaceAll("{streamName}", options.streamName)
.replaceAll("{signalingUrl}", options.signalingUrl)
.replaceAll("{roomId}", options.roomId)
.replaceAll("{videoDevice}", options.videoDevice),
);
}
private spawnProcess(name: CameraProcess["name"], command: string, args: string[], options: Required<CameraStartOptions>): CameraProcess {
const cmd = [command, ...args];
this.pushLog(`[${name}] start ${cmd.join(" ")} device=${options.videoDevice}`);
const proc = Bun.spawn(cmd, {
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
SIGNALING_URL: options.signalingUrl,
CAMERA_SIGNALING_URL: options.signalingUrl,
ROOM_ID: options.roomId,
STREAM_NAME: options.streamName,
VIDEO_DEVICE: options.videoDevice,
VIDEO_WIDTH: String(options.width),
VIDEO_HEIGHT: String(options.height),
VIDEO_FRAMERATE: String(options.framerate),
VIDEO_BITRATE: String(options.bitrate),
PIPELINE_DESC: options.pipelineDesc,
},
});
const entry: CameraProcess = {
name,
proc,
command: cmd,
env: options,
startedAt: new Date().toISOString(),
};
void this.collectOutput(name, proc.stdout);
void this.collectOutput(name, proc.stderr);
proc.exited.then((exitCode) => {
entry.exitCode = exitCode;
entry.exitedAt = new Date().toISOString();
this.pushLog(`[${name}] exited ${exitCode}`);
});
return entry;
}
private async collectOutput(name: CameraProcess["name"], stream: ReadableStream<Uint8Array> | null) {
if (!stream) return;
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) this.pushLog(`[${name}] ${trimmed}`);
}
}
if (buffer.trim()) this.pushLog(`[${name}] ${buffer.trim()}`);
} catch (error) {
this.pushLog(`[${name}] log read failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private killProcess(entry?: CameraProcess) {
if (!entry || this.hasExited(entry)) return;
entry.proc.kill();
this.pushLog(`[${entry.name}] stop requested`);
}
private hasExited(entry: CameraProcess) {
return entry.exitCode !== undefined;
}
private processStatus(entry?: CameraProcess) {
if (!entry) return { running: false };
return {
running: !this.hasExited(entry),
command: entry.command,
startedAt: entry.startedAt,
exitedAt: entry.exitedAt,
exitCode: entry.exitCode,
};
}
private pushLog(line: string) {
this.logs.push(`${new Date().toISOString()} ${line}`);
if (this.logs.length > 200) this.logs.splice(0, this.logs.length - 200);
}
}
+383
View File
@@ -0,0 +1,383 @@
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
export interface AgentConfig {
server: {
host: string;
port: number;
publicBaseUrl: string;
};
security: {
gatewayToken: string;
};
iframe: {
title: string;
allowOrigins: string[];
};
desktop: {
provider: "kasmvnc";
display: string;
webPort: number;
protocol: string;
interface: string;
geometry: string;
desktop: string;
};
camera: {
enabled: boolean;
autoStart: boolean;
streamName: string;
signalingUrl: string;
roomId: string;
videoDevice: string;
width: number;
height: number;
framerate: number;
bitrate: number;
senderCommand: string;
senderArgs: string[];
pipelineDesc: string;
};
workspace: {
root: string;
maxFileSize: number;
editableExtensions: string[];
};
ros: {
stack: "autoware" | "nav2";
domainId: string;
bridgeHost: string;
bridgePort: number;
bridgeUrl: string;
autowareImage: string;
nav2Image: string;
routeInput: "rviz_topic" | "service";
routePrefix: string;
mapPointStride: number;
mapPointLimit: number;
};
modules: {
autowareRunScript: string;
nav2RunScript: string;
};
}
const defaultConfig: AgentConfig = {
server: {
host: "127.0.0.1",
port: 3100,
publicBaseUrl: "http://127.0.0.1:3100",
},
security: {
gatewayToken: "",
},
iframe: {
title: "KasmVNC",
allowOrigins: ["file:", "app://platform-ide", "http://localhost:*", "http://127.0.0.1:*"],
},
desktop: {
provider: "kasmvnc",
display: ":1",
webPort: 6901,
protocol: "http",
interface: "0.0.0.0",
geometry: "1600x900",
desktop: "XFCE",
},
camera: {
enabled: false,
autoStart: true,
streamName: "jetson-camera",
signalingUrl: "ws://127.0.0.1:3000",
roomId: "jetson-1",
videoDevice: "/dev/video0",
width: 1920,
height: 1080,
framerate: 30,
bitrate: 4000000,
senderCommand: "python3",
senderArgs: ["scripts/jetson_webrtc_sender.py"],
pipelineDesc: "",
},
workspace: {
root: "~/autonomy-studio/workspaces",
maxFileSize: 5 * 1024 * 1024,
editableExtensions: [
".bash",
".c",
".cc",
".cfg",
".cmake",
".conf",
".cpp",
".css",
".csv",
".h",
".hpp",
".html",
".ini",
".js",
".json",
".jsx",
".launch",
".log",
".lua",
".md",
".msg",
".py",
".rs",
".rviz",
".sh",
".srv",
".toml",
".ts",
".tsx",
".txt",
".urdf",
".xml",
".yaml",
".yml",
],
},
ros: {
stack: "autoware",
domainId: "0",
bridgeHost: "127.0.0.1",
bridgePort: 8765,
bridgeUrl: "http://127.0.0.1:8765",
autowareImage: "autoware-ros-bridge",
nav2Image: "slam-ros-bridge:latest",
routeInput: "rviz_topic",
routePrefix: "/api/routing",
mapPointStride: 8,
mapPointLimit: 80000,
},
modules: {
autowareRunScript: "docker/docker_run_autoware-universe:humble-20240301-cuda.sh",
nav2RunScript: "docker/docker_run_future-nav2:humble.sh",
},
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeConfig(base: AgentConfig, patch: unknown): AgentConfig {
if (!isPlainObject(patch)) return base;
return {
server: {
...base.server,
...(isPlainObject(patch.server) ? patch.server : {}),
},
security: {
...base.security,
...(isPlainObject(patch.security) ? patch.security : {}),
},
iframe: {
...base.iframe,
...(isPlainObject(patch.iframe) ? patch.iframe : {}),
},
desktop: {
...base.desktop,
...(isPlainObject(patch.desktop) ? patch.desktop : {}),
},
camera: {
...base.camera,
...(isPlainObject(patch.camera) ? patch.camera : {}),
},
workspace: {
...base.workspace,
...(isPlainObject(patch.workspace) ? patch.workspace : {}),
},
ros: {
...base.ros,
...(isPlainObject(patch.ros) ? patch.ros : {}),
},
modules: {
...base.modules,
...(isPlainObject(patch.modules) ? patch.modules : {}),
},
} as AgentConfig;
}
export function configPath(): string {
return resolve(process.env.AGENT_CONFIG ?? "agent.config.json");
}
function normalizeStack(value: unknown): "autoware" | "nav2" {
return String(value ?? "").trim().toLowerCase() === "nav2" ? "nav2" : "autoware";
}
function normalizeRouteInput(value: unknown): "rviz_topic" | "service" {
return String(value ?? "").trim().toLowerCase() === "service" ? "service" : "rviz_topic";
}
export function normalizeConfig(config: AgentConfig): AgentConfig {
config.server.port = Number(config.server.port);
config.security ??= { gatewayToken: "" };
config.security.gatewayToken = String(config.security.gatewayToken ?? "");
config.desktop.provider = "kasmvnc";
config.desktop.display = String(config.desktop.display || ":1");
config.desktop.webPort = Number(config.desktop.webPort);
config.desktop.protocol = String(config.desktop.protocol || "http").replace(/:$/, "");
config.desktop.interface = String(config.desktop.interface || "0.0.0.0");
config.desktop.geometry = String(config.desktop.geometry || "1600x900");
config.desktop.desktop = String(config.desktop.desktop || "XFCE");
config.camera.streamName = String(config.camera.streamName);
config.camera.signalingUrl = String(config.camera.signalingUrl);
config.camera.roomId = String(config.camera.roomId);
config.camera.videoDevice = String(config.camera.videoDevice);
config.camera.width = Number(config.camera.width);
config.camera.height = Number(config.camera.height);
config.camera.framerate = Number(config.camera.framerate);
config.camera.bitrate = Number(config.camera.bitrate);
config.camera.senderCommand = String(config.camera.senderCommand);
config.camera.pipelineDesc = String(config.camera.pipelineDesc ?? "");
config.workspace.root = String(config.workspace.root);
config.workspace.maxFileSize = Number(config.workspace.maxFileSize);
config.workspace.editableExtensions = Array.isArray(config.workspace.editableExtensions)
? config.workspace.editableExtensions.map((extension) => String(extension).toLowerCase())
: defaultConfig.workspace.editableExtensions;
config.camera.senderArgs = Array.isArray(config.camera.senderArgs)
? config.camera.senderArgs.map(String)
: defaultConfig.camera.senderArgs;
config.iframe.allowOrigins = Array.isArray(config.iframe.allowOrigins)
? config.iframe.allowOrigins.map(String)
: defaultConfig.iframe.allowOrigins;
config.ros.stack = normalizeStack(config.ros.stack);
config.ros.domainId = /^\d+$/.test(String(config.ros.domainId ?? "")) ? String(config.ros.domainId) : "0";
config.ros.bridgeHost = String(config.ros.bridgeHost || "127.0.0.1");
config.ros.bridgePort = Number(config.ros.bridgePort);
config.ros.bridgeUrl = String(config.ros.bridgeUrl || `http://${config.ros.bridgeHost}:${config.ros.bridgePort}`).replace(/\/+$/, "");
config.ros.autowareImage = String(config.ros.autowareImage || defaultConfig.ros.autowareImage);
config.ros.nav2Image = String(config.ros.nav2Image || defaultConfig.ros.nav2Image);
config.ros.routeInput = normalizeRouteInput(config.ros.routeInput);
config.ros.routePrefix = String(config.ros.routePrefix || "/api/routing");
config.ros.mapPointStride = Number(config.ros.mapPointStride);
config.ros.mapPointLimit = Number(config.ros.mapPointLimit);
config.modules.autowareRunScript = String(config.modules.autowareRunScript || defaultConfig.modules.autowareRunScript);
config.modules.nav2RunScript = String(config.modules.nav2RunScript || defaultConfig.modules.nav2RunScript);
if (process.env.HOST) config.server.host = process.env.HOST;
if (process.env.PORT) config.server.port = Number(process.env.PORT);
if (process.env.KASMVNC_DISPLAY) config.desktop.display = process.env.KASMVNC_DISPLAY;
if (process.env.KASMVNC_WEB_PORT) config.desktop.webPort = Number(process.env.KASMVNC_WEB_PORT);
if (process.env.KASMVNC_PROTOCOL) config.desktop.protocol = process.env.KASMVNC_PROTOCOL.replace(/:$/, "");
if (process.env.KASMVNC_INTERFACE) config.desktop.interface = process.env.KASMVNC_INTERFACE;
if (process.env.KASMVNC_GEOMETRY) config.desktop.geometry = process.env.KASMVNC_GEOMETRY;
if (process.env.KASMVNC_DESKTOP) config.desktop.desktop = process.env.KASMVNC_DESKTOP;
if (process.env.CAMERA_ENABLED) config.camera.enabled = process.env.CAMERA_ENABLED === "true";
if (process.env.CAMERA_SIGNALING_URL) config.camera.signalingUrl = process.env.CAMERA_SIGNALING_URL;
if (process.env.CAMERA_STREAM_NAME) config.camera.streamName = process.env.CAMERA_STREAM_NAME;
if (process.env.CAMERA_ROOM_ID) config.camera.roomId = process.env.CAMERA_ROOM_ID;
if (process.env.CAMERA_VIDEO_DEVICE) config.camera.videoDevice = process.env.CAMERA_VIDEO_DEVICE;
if (process.env.CAMERA_WIDTH) config.camera.width = Number(process.env.CAMERA_WIDTH);
if (process.env.CAMERA_HEIGHT) config.camera.height = Number(process.env.CAMERA_HEIGHT);
if (process.env.CAMERA_FRAMERATE) config.camera.framerate = Number(process.env.CAMERA_FRAMERATE);
if (process.env.CAMERA_BITRATE) config.camera.bitrate = Number(process.env.CAMERA_BITRATE);
if (process.env.CAMERA_PIPELINE_DESC) config.camera.pipelineDesc = process.env.CAMERA_PIPELINE_DESC;
if (process.env.WORKSPACE_ROOT) config.workspace.root = process.env.WORKSPACE_ROOT;
if (process.env.WORKSPACE_MAX_FILE_SIZE) config.workspace.maxFileSize = Number(process.env.WORKSPACE_MAX_FILE_SIZE);
if (process.env.ROBOT_STACK) config.ros.stack = normalizeStack(process.env.ROBOT_STACK);
if (process.env.ROS_DOMAIN_ID) config.ros.domainId = /^\d+$/.test(process.env.ROS_DOMAIN_ID) ? process.env.ROS_DOMAIN_ID : "0";
if (process.env.ROS_BRIDGE_HOST) config.ros.bridgeHost = process.env.ROS_BRIDGE_HOST;
if (process.env.ROS_BRIDGE_PORT) config.ros.bridgePort = Number(process.env.ROS_BRIDGE_PORT);
if (process.env.ROS_BRIDGE_URL) config.ros.bridgeUrl = process.env.ROS_BRIDGE_URL.replace(/\/+$/, "");
if (process.env.AUTOWARE_ROUTE_INPUT) config.ros.routeInput = normalizeRouteInput(process.env.AUTOWARE_ROUTE_INPUT);
if (process.env.AUTOWARE_ROUTE_PREFIX) config.ros.routePrefix = process.env.AUTOWARE_ROUTE_PREFIX;
if (process.env.MAP_POINT_STRIDE) config.ros.mapPointStride = Number(process.env.MAP_POINT_STRIDE);
if (process.env.MAP_POINT_LIMIT) config.ros.mapPointLimit = Number(process.env.MAP_POINT_LIMIT);
if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) {
throw new Error("server.port must be a valid TCP port.");
}
if (!Number.isInteger(config.desktop.webPort) || config.desktop.webPort < 1 || config.desktop.webPort > 65535) {
throw new Error("desktop.webPort must be a valid TCP port.");
}
if (!Number.isFinite(config.workspace.maxFileSize) || config.workspace.maxFileSize < 1) {
throw new Error("workspace.maxFileSize must be a positive number.");
}
if (!Number.isFinite(config.camera.width) || config.camera.width < 1) {
throw new Error("camera.width must be a positive number.");
}
if (!Number.isFinite(config.camera.height) || config.camera.height < 1) {
throw new Error("camera.height must be a positive number.");
}
if (!Number.isFinite(config.camera.framerate) || config.camera.framerate < 1) {
throw new Error("camera.framerate must be a positive number.");
}
if (!Number.isFinite(config.camera.bitrate) || config.camera.bitrate < 1) {
throw new Error("camera.bitrate must be a positive number.");
}
if (!Number.isInteger(config.ros.bridgePort) || config.ros.bridgePort < 1 || config.ros.bridgePort > 65535) {
throw new Error("ros.bridgePort must be a valid TCP port.");
}
if (!Number.isFinite(config.ros.mapPointStride) || config.ros.mapPointStride < 1) {
throw new Error("ros.mapPointStride must be a positive number.");
}
if (!Number.isFinite(config.ros.mapPointLimit) || config.ros.mapPointLimit < 1000) {
throw new Error("ros.mapPointLimit must be at least 1000.");
}
return config;
}
export function loadConfig(): AgentConfig {
const rawConfig = existsSync(configPath()) ? JSON.parse(readFileSync(configPath(), "utf8")) : {};
return normalizeConfig(mergeConfig(defaultConfig, rawConfig));
}
export async function saveConfigPatch(current: AgentConfig, patch: unknown): Promise<AgentConfig> {
const raw = existsSync(configPath()) ? JSON.parse(readFileSync(configPath(), "utf8")) : {};
const merged = mergeConfig(mergeConfig(defaultConfig, raw), patch);
const normalized = normalizeConfig(merged);
await writeFile(configPath(), `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
Object.assign(current.server, normalized.server);
Object.assign(current.iframe, normalized.iframe);
Object.assign(current.desktop, normalized.desktop);
Object.assign(current.camera, normalized.camera);
Object.assign(current.workspace, normalized.workspace);
Object.assign(current.ros, normalized.ros);
Object.assign(current.modules, normalized.modules);
return current;
}
export function getConfigSummary(config: AgentConfig) {
const kasmProtocol = config.desktop.protocol;
const kasmPort = config.desktop.webPort;
return {
server: config.server,
iframe: {
title: "KasmVNC",
},
remoteDesktop: {
provider: "kasmvnc",
display: config.desktop.display,
desktop: config.desktop.desktop,
geometry: config.desktop.geometry,
protocol: kasmProtocol,
port: kasmPort,
url: `${kasmProtocol}://127.0.0.1:${kasmPort}`,
},
camera: {
enabled: config.camera.enabled,
autoStart: config.camera.autoStart,
streamName: config.camera.streamName,
signalingUrl: config.camera.signalingUrl,
roomId: config.camera.roomId,
videoDevice: config.camera.videoDevice,
width: config.camera.width,
height: config.camera.height,
framerate: config.camera.framerate,
bitrate: config.camera.bitrate,
senderCommand: config.camera.senderCommand,
senderArgs: config.camera.senderArgs,
pipelineDescConfigured: config.camera.pipelineDesc.length > 0,
},
workspace: config.workspace,
ros: config.ros,
modules: config.modules,
};
}
+155
View File
@@ -0,0 +1,155 @@
import { Hono } from "hono";
type ProcessHandle = ReturnType<typeof Bun.spawn>;
type DisplayProcess = {
proc: ProcessHandle;
command: string[];
startedAt: string;
exitCode?: number | null;
};
const displayNumber = process.env.REMOTE_DISPLAY || ":2";
const screen = process.env.REMOTE_DISPLAY_SCREEN || "1920x1080x24";
const displayIndex = displayNumber.replace(/^:/, "");
function runShell(command: string, timeout = 3000): Promise<{ ok: boolean; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const proc = Bun.spawn(["sh", "-lc", command], {
stdout: "pipe",
stderr: "pipe",
});
const timer = setTimeout(() => {
proc.kill();
resolve({ ok: false, stdout: "", stderr: `${command} timed out` });
}, timeout);
Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]).then(([stdout, stderr, exitCode]) => {
clearTimeout(timer);
resolve({ ok: exitCode === 0, stdout, stderr });
}).catch((error) => {
clearTimeout(timer);
resolve({ ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
});
});
}
function isAlive(proc?: DisplayProcess): boolean {
return Boolean(proc && proc.exitCode === undefined);
}
async function commandExists(command: string): Promise<boolean> {
return (await runShell(`command -v ${command}`)).ok;
}
async function isDisplayProcessRunning(): Promise<boolean> {
return (await runShell(`pgrep -f "Xvfb ${displayNumber}" >/dev/null || pgrep -f "Xorg ${displayNumber}" >/dev/null`)).ok;
}
async function isDisplayAvailable(): Promise<boolean> {
if (await commandExists("xdpyinfo")) {
return (await runShell(`DISPLAY=${displayNumber} xdpyinfo >/dev/null`, 2000)).ok;
}
return (await runShell(`test -S /tmp/.X11-unix/X${displayIndex}`)).ok && await isDisplayProcessRunning();
}
async function waitForDisplay(timeoutMs = 3000): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isDisplayAvailable()) return true;
await Bun.sleep(150);
}
return false;
}
async function cleanupDisplay(): Promise<void> {
await runShell(`pkill -f "openbox" || true; pkill -f "Xvfb ${displayNumber}" || true; rm -f /tmp/.X${displayIndex}-lock /tmp/.X11-unix/X${displayIndex}`, 3000);
}
export function createDisplayApi(): Hono {
const app = new Hono();
let xvfb: DisplayProcess | undefined;
let openbox: DisplayProcess | undefined;
const logs: string[] = [];
function pushLog(line: string) {
logs.push(`${new Date().toISOString()} ${line}`);
if (logs.length > 100) logs.splice(0, logs.length - 100);
}
function track(procInfo: DisplayProcess) {
procInfo.proc.exited.then((exitCode) => {
procInfo.exitCode = exitCode;
pushLog(`${procInfo.command.join(" ")} exited ${exitCode}`);
}).catch((error) => {
procInfo.exitCode = -1;
pushLog(`${procInfo.command.join(" ")} failed ${error instanceof Error ? error.message : String(error)}`);
});
}
async function statusPayload() {
const running = await isDisplayAvailable();
return {
ok: true,
running,
display: displayNumber,
screen,
xvfb: isAlive(xvfb),
openbox: isAlive(openbox),
logs: logs.slice(-50),
};
}
app.get("/status", async (c) => c.json(await statusPayload()));
app.post("/start", async (c) => {
if (!(await isDisplayAvailable())) {
if (!(await commandExists("Xvfb"))) {
return c.json({ ok: false, message: "Xvfb is not installed. Run sudo apt install -y xvfb openbox and try again.", status: await statusPayload() }, 500);
}
pushLog(`${displayNumber} display is not responding. Starting a new display.`);
await cleanupDisplay();
const command = ["Xvfb", displayNumber, "-screen", "0", screen, "-ac"];
pushLog(`start ${command.join(" ")}`);
const proc = Bun.spawn(command, {
stdout: "ignore",
stderr: "pipe",
});
xvfb = { proc, command, startedAt: new Date().toISOString() };
track(xvfb);
if (!(await waitForDisplay())) {
return c.json({ ok: false, message: `Failed to start ${displayNumber} display. Check agent.log and Xvfb installation status.`, status: await statusPayload() }, 500);
}
}
if (!isAlive(openbox) && await commandExists("openbox")) {
const command = ["openbox"];
pushLog(`start DISPLAY=${displayNumber} ${command.join(" ")}`);
const proc = Bun.spawn(command, {
env: { ...process.env, DISPLAY: displayNumber },
stdout: "ignore",
stderr: "pipe",
});
openbox = { proc, command, startedAt: new Date().toISOString() };
track(openbox);
}
return c.json({ ok: true, message: `${displayNumber} display is ready`, status: await statusPayload() });
});
app.post("/stop", async (c) => {
if (isAlive(openbox)) openbox?.proc.kill();
if (isAlive(xvfb)) xvfb?.proc.kill();
await cleanupDisplay();
pushLog("stop requested");
return c.json({ ok: true, status: await statusPayload() });
});
return app;
}
+192
View File
@@ -0,0 +1,192 @@
import { Hono } from "hono";
import type { AgentConfig } from "./config.ts";
type CommandResult = {
ok: boolean;
stdout: string;
stderr: string;
exitCode: number;
};
function desktopSettings(config: AgentConfig) {
const display = config.desktop.display || ":1";
return {
display,
displayNumber: Number(display.replace(/^:/, "")) || 1,
webPort: Number(config.desktop.webPort || 6901),
protocol: (config.desktop.protocol || "http").replace(/:$/, ""),
bindAddress: config.desktop.interface || "0.0.0.0",
defaultGeometry: config.desktop.geometry || "1600x900",
desktopEnvironment: config.desktop.desktop || "XFCE",
};
}
function normalizeGeometry(value: unknown, fallback: string): string {
const text = String(value ?? "").trim();
return /^\d{3,5}x\d{3,5}$/.test(text) ? text : fallback;
}
async function runCommand(command: string, args: string[], timeout = 5000): Promise<CommandResult> {
const proc = Bun.spawn([command, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const timer = setTimeout(() => proc.kill(), timeout);
try {
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return {
ok: exitCode === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
};
} finally {
clearTimeout(timer);
}
}
async function commandExists(command: string): Promise<boolean> {
return (await runCommand("sh", ["-lc", `command -v ${command}`], 2000)).ok;
}
async function detectVncServerCommand(): Promise<string> {
if (await commandExists("kasmvncserver")) return "kasmvncserver";
if (await commandExists("vncserver")) return "vncserver";
return "vncserver";
}
async function isKasmVncInstalled(): Promise<boolean> {
const command = await detectVncServerCommand();
if (!(await commandExists(command))) return false;
if (command === "kasmvncserver") return true;
const help = await runCommand(command, ["-h"], 3000);
return /kasm/i.test(`${help.stdout}\n${help.stderr}`);
}
async function isRunning(display: string): Promise<boolean> {
const command = await detectVncServerCommand();
const list = await runCommand(command, ["-list"], 3000);
if (new RegExp(`\\${display}\\b`).test(`${list.stdout}\n${list.stderr}`)) return true;
const pgrep = await runCommand("pgrep", ["-f", `Xvnc.*\\${display}\\b`], 3000);
return pgrep.ok;
}
async function isPortListening(webPort: number): Promise<boolean> {
const result = await runCommand("sh", ["-lc", `ss -ltn 2>/dev/null | grep -q ':${webPort} ' || netstat -ltn 2>/dev/null | grep -q ':${webPort} '`], 3000);
return result.ok;
}
function publicUrl(host: string, protocol: string, webPort: number): string {
const hostname = host.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
return `${protocol}://${hostname}:${webPort}`;
}
export function createKasmVncApi(config: AgentConfig): Hono {
const app = new Hono();
const logs: string[] = [];
function pushLog(line: string) {
logs.push(`${new Date().toISOString()} ${line}`);
if (logs.length > 120) logs.splice(0, logs.length - 120);
}
async function statusPayload(requestUrl?: string) {
const settings = desktopSettings(config);
const host = requestUrl ? new URL(requestUrl).hostname : "127.0.0.1";
const installed = await isKasmVncInstalled();
const running = installed ? await isRunning(settings.display) : false;
const webListening = running ? await isPortListening(settings.webPort) : false;
return {
ok: true,
installed,
running,
webListening,
display: settings.display,
displayNumber: settings.displayNumber,
desktop: settings.desktopEnvironment,
geometry: settings.defaultGeometry,
protocol: settings.protocol,
port: settings.webPort,
url: publicUrl(host, settings.protocol, settings.webPort),
logs: logs.slice(-80),
};
}
app.get("/status", async (c) => c.json(await statusPayload(c.req.url)));
app.post("/start", async (c) => {
const command = await detectVncServerCommand();
if (!(await commandExists(command)) || !(await isKasmVncInstalled())) {
return c.json({
ok: false,
message: "KasmVNC vncserver command was not found.",
status: await statusPayload(c.req.url),
}, 500);
}
const body = await c.req.json<{ geometry?: string; width?: number; height?: number }>().catch(() => undefined);
const settings = desktopSettings(config);
const geometry = normalizeGeometry(body?.geometry ?? (
body?.width && body?.height ? `${Math.round(body.width)}x${Math.round(body.height)}` : undefined
), settings.defaultGeometry);
if (await isRunning(settings.display)) {
return c.json({ ok: true, message: "KasmVNC session is already running.", status: await statusPayload(c.req.url) });
}
const args = [
settings.display,
"-select-de",
settings.desktopEnvironment,
"-geometry",
geometry,
"-depth",
"24",
"-websocketPort",
String(settings.webPort),
"-interface",
settings.bindAddress,
];
pushLog(`start ${command} ${args.join(" ")}`);
const result = await runCommand(command, args, 15000);
if (!result.ok) {
pushLog(`start failed ${result.stderr || result.stdout || result.exitCode}`);
return c.json({
ok: false,
message: result.stderr || result.stdout || "Failed to start KasmVNC.",
status: await statusPayload(c.req.url),
}, 500);
}
pushLog(result.stdout || "started");
return c.json({ ok: true, message: "KasmVNC started.", status: await statusPayload(c.req.url) });
});
app.post("/stop", async (c) => {
const command = await detectVncServerCommand();
if (!(await commandExists(command)) || !(await isKasmVncInstalled())) {
return c.json({ ok: false, message: "KasmVNC vncserver command was not found.", status: await statusPayload(c.req.url) }, 500);
}
const settings = desktopSettings(config);
const result = await runCommand(command, ["-kill", settings.display], 8000);
if (!result.ok && await isRunning(settings.display)) {
pushLog(`stop failed ${result.stderr || result.stdout || result.exitCode}`);
return c.json({
ok: false,
message: result.stderr || result.stdout || "Failed to stop KasmVNC.",
status: await statusPayload(c.req.url),
}, 500);
}
pushLog(result.stdout || "stopped");
return c.json({ ok: true, message: "KasmVNC stopped.", status: await statusPayload(c.req.url) });
});
return app;
}
+594
View File
@@ -0,0 +1,594 @@
import { Hono } from "hono";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import type { Server, ServerWebSocket } from "bun";
import type { AgentConfig } from "./config.ts";
type ModuleWorkspaceInfo = {
name: string;
version: string;
containerName: string;
containerKey: string;
path: string;
folderName: string;
};
type ModuleDockerStatus = ModuleWorkspaceInfo & {
running: boolean;
exists: boolean;
status: string;
};
const ROS_BRIDGE_MODULES: ModuleWorkspaceInfo[] = [
{
name: "ROS Bridge · Autoware",
version: "remote",
containerName: "ros-bridge",
containerKey: "autoware-ros-bridge",
path: "",
folderName: "__bridge_autoware",
},
{
name: "ROS Bridge · Nav2",
version: "remote",
containerName: "ros-bridge",
containerKey: "nav2-ros-bridge",
path: "",
folderName: "__bridge_nav2",
},
];
const MODULE_PARAMS_CACHE_FILE = ".platform-module-params.json";
function normalizeRosDomainId(value?: string): string {
const trimmed = String(value ?? process.env.ROS_DOMAIN_ID ?? "").trim();
return /^\d+$/.test(trimmed) ? trimmed : "0";
}
type TerminalSocketData = {
cwd: string;
cols: number;
rows: number;
proc?: ReturnType<typeof Bun.spawn>;
};
function expandHome(path: string): string {
if (path === "~") return process.env.HOME ?? "/root";
if (path.startsWith("~/")) return join(process.env.HOME ?? "/root", path.slice(2));
return path;
}
function workspaceRoot(config: AgentConfig): string {
return resolve(expandHome(config.workspace.root));
}
function resolveWorkspacePath(config: AgentConfig, inputPath = ""): string {
const root = workspaceRoot(config);
const target = resolve(root, inputPath);
if (target !== root && !target.startsWith(`${root}${sep}`)) {
throw new Error("Paths outside the workspace are not allowed");
}
return target;
}
function parseModuleWorkspaceFolder(folderName: string): ModuleWorkspaceInfo | null {
const match = folderName.match(/^\[([^\]]+)\](.+)_v([^/]+)_ws$/);
if (!match) return null;
const [, containerName, name, version] = match;
return {
name,
version,
containerName,
containerKey: `${containerName}-${name}_v${version}`,
path: folderName,
folderName,
};
}
function runCommand(command: string, args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolveCommand, rejectCommand) => {
const proc = Bun.spawn([command, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const timer = setTimeout(() => {
proc.kill();
rejectCommand(new Error(`${command} timed out`));
}, timeout);
Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]).then(([stdout, stderr, exitCode]) => {
clearTimeout(timer);
if (exitCode !== 0) {
rejectCommand(new Error([stderr.trim(), stdout.trim()].filter(Boolean).join("\n") || `${command} exited ${exitCode}`));
return;
}
resolveCommand({ stdout, stderr });
}).catch((error) => {
clearTimeout(timer);
rejectCommand(error);
});
});
}
async function readModuleWorkspaces(config: AgentConfig): Promise<ModuleWorkspaceInfo[]> {
const root = workspaceRoot(config);
await mkdir(root, { recursive: true });
const entries = await readdir(root, { withFileTypes: true });
const moduleWorkspaces = entries
.filter((entry) => entry.isDirectory())
.map((entry) => parseModuleWorkspaceFolder(entry.name))
.filter((entry): entry is ModuleWorkspaceInfo => Boolean(entry))
.filter((entry) => entry.containerName !== "ros-bridge")
.sort((a, b) => a.containerName.localeCompare(b.containerName) || a.name.localeCompare(b.name));
return [...moduleWorkspaces, ...ROS_BRIDGE_MODULES];
}
async function readDockerStatuses(): Promise<Map<string, { running: boolean; exists: boolean; status: string }>> {
const statuses = new Map<string, { running: boolean; exists: boolean; status: string }>();
const { stdout } = await runCommand("docker", ["ps", "-a", "--format", "{{.Names}}\t{{.Status}}"], 5000);
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const [name, status = ""] = trimmed.split("\t");
statuses.set(name, {
exists: true,
running: /^Up\b/i.test(status),
status,
});
}
return statuses;
}
async function readModuleLogs(containerKey: string): Promise<{ ok: boolean; logs: string; message?: string }> {
if (!isAllowedContainerName(containerKey)) {
return { ok: false, logs: "", message: "Invalid container name format" };
}
try {
const { stdout, stderr } = await runCommand("docker", ["logs", "--tail", "300", containerKey], 5000);
return { ok: true, logs: [stdout, stderr].filter(Boolean).join("\n") };
} catch (error) {
return { ok: false, logs: "", message: error instanceof Error ? error.message : String(error) };
}
}
function autowareDockerRunScript(config: AgentConfig): string {
return join(workspaceRoot(config), config.modules.autowareRunScript);
}
function nav2DockerRunScript(config: AgentConfig): string {
return join(workspaceRoot(config), config.modules.nav2RunScript);
}
function isNav2Module(workspace: ModuleWorkspaceInfo): boolean {
return workspace.name.startsWith("nav2-");
}
async function readModuleRosParams(config: AgentConfig, folderName: string): Promise<string> {
try {
const runScript = await readFile(join(workspaceRoot(config), folderName, "run.sh"), "utf8");
const match = runScript.match(/^ROS_PARAM=(['"])(.*?)\1/m);
return match?.[2] ?? "";
} catch {
return "";
}
}
function extractRosParamsFromCommand(command: string): string | null {
const quoted = command.match(/\/nav2_ws\/run\.sh\s+"([^"]*)"/);
if (quoted) return quoted[1].trim();
const singleQuoted = command.match(/\/nav2_ws\/run\.sh\s+'([^']*)'/);
if (singleQuoted) return singleQuoted[1].trim();
const unquoted = command.match(/\/nav2_ws\/run\.sh\s+([^|]+)/);
return unquoted?.[1]?.trim() || null;
}
async function readContainerRosParams(containerKey: string): Promise<string> {
try {
const { stdout } = await runCommand("docker", ["inspect", containerKey, "--format", "{{json .Config.Cmd}}"], 5000);
const cmd = JSON.parse(stdout.trim()) as string[];
if (!Array.isArray(cmd)) return "";
return extractRosParamsFromCommand(cmd.join(" ")) ?? "";
} catch {
return "";
}
}
async function readCachedRosParams(config: AgentConfig, containerKey: string): Promise<string> {
try {
const raw = await readFile(join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE), "utf8");
const parsed = JSON.parse(raw) as Record<string, string>;
return typeof parsed[containerKey] === "string" ? parsed[containerKey] : "";
} catch {
return "";
}
}
async function writeCachedRosParam(config: AgentConfig, containerKey: string, rosParams: string): Promise<void> {
const trimmed = rosParams.trim();
if (!trimmed) return;
const cachePath = join(workspaceRoot(config), MODULE_PARAMS_CACHE_FILE);
let parsed: Record<string, string> = {};
try {
parsed = JSON.parse(await readFile(cachePath, "utf8")) as Record<string, string>;
} catch {
parsed = {};
}
parsed[containerKey] = trimmed;
await writeFile(cachePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
async function syncRunningModuleRosParams(config: AgentConfig, workspaces: ModuleWorkspaceInfo[]): Promise<void> {
for (const workspace of workspaces) {
if (!isNav2Module(workspace)) continue;
const rosParams = await readContainerRosParams(workspace.containerKey);
if (rosParams) await writeCachedRosParam(config, workspace.containerKey, rosParams);
}
}
async function resolveModuleRosParams(config: AgentConfig, workspace: ModuleWorkspaceInfo): Promise<string> {
const liveParams = await readContainerRosParams(workspace.containerKey);
if (liveParams) {
await writeCachedRosParam(config, workspace.containerKey, liveParams);
return liveParams;
}
const cachedParams = await readCachedRosParams(config, workspace.containerKey);
if (cachedParams) return cachedParams;
return readModuleRosParams(config, workspace.folderName);
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
async function findWorkspaceFolder(config: AgentConfig, pattern: RegExp): Promise<string | null> {
try {
const root = workspaceRoot(config);
const entries = await readdir(root, { withFileTypes: true });
const match = entries.find((entry) => entry.isDirectory() && pattern.test(entry.name));
return match ? join(root, match.name) : null;
} catch {
return null;
}
}
async function buildNav2ConfigMounts(config: AgentConfig): Promise<string> {
const root = workspaceRoot(config);
const mounts: string[] = [];
const addIfExists = async (hostPath: string, containerPath: string) => {
if (await pathExists(hostPath)) mounts.push(`${hostPath}:${containerPath}:rw`);
};
await addIfExists(join(root, "config", "map"), "/nav2_ws/shared_maps");
await addIfExists(join(root, "config", "map"), "/tmp/.ros");
await addIfExists(join(root, "config", "map", "selectedMap"), "/nav2_ws/selected_map");
await addIfExists(join(root, "config", "nav2_config", "vehicle"), "/nav2_ws/install/vehicle_pkg/share/vehicle_pkg/config");
await addIfExists(join(root, "config", "nav2_config", "perception"), "/nav2_ws/install/perception_pkg/share/perception_pkg/config");
const localization3d = await findWorkspaceFolder(config, /^\[furive-localization\]nav2-localization3D_v[^/]+_ws$/);
if (localization3d) {
await addIfExists(join(localization3d, "install"), "/extra_ws/localization3d/install");
}
return mounts.join(";");
}
function isRosBridgeContainerName(containerKey: string): boolean {
return containerKey === "autoware-ros-bridge" || containerKey === "nav2-ros-bridge";
}
function isAllowedContainerName(containerKey: string): boolean {
return isRosBridgeContainerName(containerKey) || /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey);
}
function bridgeStackFromFolderName(folderName: string): "autoware" | "nav2" | null {
if (folderName === "__bridge_autoware") return "autoware";
if (folderName === "__bridge_nav2") return "nav2";
const workspace = parseModuleWorkspaceFolder(folderName);
if (workspace?.containerName === "ros-bridge" && workspace.name === "autoware-ros-bridge") return "autoware";
if (workspace?.containerName === "ros-bridge" && workspace.name === "nav2-ros-bridge") return "nav2";
return null;
}
async function findPlanningWorkspacePath(config: AgentConfig): Promise<string | null> {
try {
const root = workspaceRoot(config);
const entries = await readdir(root, { withFileTypes: true });
const planning = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.find((name) => /^\[furive-planning\].+_ws$/.test(name));
return planning ? join(root, planning) : null;
} catch {
return null;
}
}
async function startRosBridgeContainer(config: AgentConfig, stack: "autoware" | "nav2", rosDomainId = "0") {
const containerKey = stack === "autoware" ? "autoware-ros-bridge" : "nav2-ros-bridge";
const otherContainerKey = stack === "autoware" ? "nav2-ros-bridge" : "autoware-ros-bridge";
const dockerStatuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>());
if (dockerStatuses.get(containerKey)?.running) return { ok: true, message: "Already running" };
if (dockerStatuses.get(otherContainerKey)?.running) {
await runCommand("docker", ["stop", otherContainerKey], 15000).catch(() => ({ stdout: "", stderr: "" }));
}
if (dockerStatuses.get(containerKey)?.exists && !dockerStatuses.get(containerKey)?.running) {
await runCommand("docker", ["rm", "-f", containerKey], 10000).catch(() => ({ stdout: "", stderr: "" }));
}
const args = [
"run",
"--rm",
"-d",
"--name",
containerKey,
"--net=host",
"-v",
"/dev/shm:/dev/shm",
"-e",
`ROS_DOMAIN_ID=${normalizeRosDomainId(rosDomainId)}`,
"-e",
"ROS_LOCALHOST_ONLY=0",
"-e",
`ROBOT_STACK=${stack}`,
"-e",
`AUTOWARE_ROUTE_INPUT=${config.ros.routeInput}`,
"-e",
`AUTOWARE_ROUTE_PREFIX=${config.ros.routePrefix}`,
"-e",
"RCUTILS_COLORIZED_OUTPUT=1",
"-e",
"RCUTILS_LOGGING_BUFFERED_STREAM=0",
"-e",
"PYTHONUNBUFFERED=1",
"-e",
`MAP_POINT_STRIDE=${config.ros.mapPointStride}`,
"-e",
`MAP_POINT_LIMIT=${config.ros.mapPointLimit}`,
];
if (stack === "autoware") {
args.push("-e", "AUTOWARE_ROUTE_INPUT=rviz_topic");
const planningWorkspacePath = await findPlanningWorkspacePath(config);
if (planningWorkspacePath) args.push("-v", `${planningWorkspacePath}:/autoware_ws:ro`);
}
if (stack === "nav2") {
const root = workspaceRoot(config);
await mkdir(root, { recursive: true });
args.push("-v", `${root}:/workspace`);
args.push("-e", "SAVED_MAPS_PATH=/workspace/maps");
}
args.push(stack === "autoware" ? config.ros.autowareImage : config.ros.nav2Image);
if (stack === "nav2") args.push("bash", "-lc", "exec python3 /ros_bridge/bridge.py");
await runCommand("docker", args, 15000);
return { ok: true, message: "Bridge started" };
}
export async function statusList(config: AgentConfig): Promise<ModuleDockerStatus[]> {
const workspaces = await readModuleWorkspaces(config);
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>());
await syncRunningModuleRosParams(config, workspaces).catch(() => undefined);
return workspaces.map((workspace) => {
const dockerStatus = statuses.get(workspace.containerKey);
return {
...workspace,
running: Boolean(dockerStatus?.running),
exists: Boolean(dockerStatus?.exists),
status: dockerStatus?.status ?? "Not created",
};
});
}
export async function startModule(config: AgentConfig, folderName: string, rosDomainId = "0") {
const bridgeStack = bridgeStackFromFolderName(folderName);
if (bridgeStack) return startRosBridgeContainer(config, bridgeStack, rosDomainId);
const workspace = parseModuleWorkspaceFolder(folderName);
if (!workspace) return { ok: false, message: "Invalid module folder name format" };
const nav2Module = isNav2Module(workspace);
const scriptPath = nav2Module ? nav2DockerRunScript(config) : autowareDockerRunScript(config);
try {
await stat(scriptPath);
} catch {
return { ok: false, message: `${scriptPath} was not found` };
}
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>());
const current = statuses.get(workspace.containerKey);
if (current?.running) return { ok: true, message: "Already running" };
if (current?.exists && !current.running) {
await runCommand("docker", ["rm", "-f", workspace.containerKey], 10000).catch(() => ({ stdout: "", stderr: "" }));
}
const root = workspaceRoot(config);
const image = nav2Module ? "future-nav2:humble" : "ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda";
const rosParams = nav2Module ? await resolveModuleRosParams(config, workspace) : "";
const env: Record<string, string | undefined> = {
...process.env,
SHARD_DIR: root,
ROS_DOMAIN_ID: normalizeRosDomainId(rosDomainId || config.ros.domainId),
ROBOT_STACK: nav2Module ? "nav2" : "autoware",
AUTOWARE_ROUTE_INPUT: config.ros.routeInput,
AUTOWARE_ROUTE_PREFIX: config.ros.routePrefix,
MAP_POINT_STRIDE: String(config.ros.mapPointStride),
MAP_POINT_LIMIT: String(config.ros.mapPointLimit),
};
if (nav2Module) {
env.CONFIG_MOUNTS = await buildNav2ConfigMounts(config);
}
const child = Bun.spawn([
"bash",
scriptPath,
workspace.containerKey,
workspace.folderName,
"localhost",
"localhost",
image,
rosParams,
normalizeRosDomainId(rosDomainId),
"-d",
], {
cwd: root,
env,
stdout: "ignore",
stderr: "ignore",
});
child.unref();
return { ok: true, message: "Start request sent" };
}
export async function stopModule(config: AgentConfig, containerKey: string) {
if (!isAllowedContainerName(containerKey)) {
return { ok: false, message: "Invalid container name format" };
}
try {
const rosParams = await readContainerRosParams(containerKey);
if (rosParams) await writeCachedRosParam(config, containerKey, rosParams);
await runCommand("docker", ["stop", containerKey], 15000);
return { ok: true, message: "Stopped" };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
export function createModuleApi(config: AgentConfig): Hono {
const app = new Hono();
app.get("/workspaces", async (c) => {
try {
return c.json({ ok: true, workspaces: await readModuleWorkspaces(config) });
} catch (error) {
return c.json({ ok: false, message: error instanceof Error ? error.message : "Failed to load module list", workspaces: [] }, 500);
}
});
app.get("/docker-statuses", async (c) => {
try {
return c.json({ ok: true, statuses: await statusList(config) });
} catch (error) {
return c.json({ ok: false, message: error instanceof Error ? error.message : "Failed to load Docker status", statuses: [] }, 500);
}
});
app.get("/logs", async (c) => {
return c.json(await readModuleLogs(c.req.query("containerKey") ?? ""));
});
app.post("/start", async (c) => {
try {
const body = await c.req.json<{ folderName?: string; rosDomainId?: string }>().catch(() => undefined);
return c.json(await startModule(config, body?.folderName ?? "", body?.rosDomainId));
} catch (error) {
return c.json({ ok: false, message: error instanceof Error ? error.message : String(error) }, 500);
}
});
app.post("/stop", async (c) => {
try {
const body = await c.req.json<{ containerKey?: string }>().catch(() => undefined);
return c.json(await stopModule(config, body?.containerKey ?? ""));
} catch (error) {
return c.json({ ok: false, message: error instanceof Error ? error.message : String(error) }, 500);
}
});
return app;
}
export function handleModuleTerminalUpgrade(config: AgentConfig, req: Request, server: Server<TerminalSocketData>): Response {
const url = new URL(req.url);
let cwd: string;
try {
cwd = resolveWorkspacePath(config, url.searchParams.get("path") ?? "");
} catch (error) {
return new Response(error instanceof Error ? error.message : String(error), { status: 400 });
}
const cols = Math.max(20, Math.min(300, Number(url.searchParams.get("cols") ?? 80) || 80));
const rows = Math.max(5, Math.min(120, Number(url.searchParams.get("rows") ?? 24) || 24));
const upgraded = server.upgrade(req, { data: { cwd, cols, rows } });
return upgraded ? new Response(null) : new Response("WebSocket upgrade failed", { status: 400 });
}
export const moduleTerminalWebSocket = {
open(ws: ServerWebSocket<TerminalSocketData>) {
const shellPath = process.env.SHELL || "/bin/bash";
const command = `stty cols ${ws.data.cols} rows ${ws.data.rows}; exec '${shellPath.replace(/'/g, "'\\''")}' -i`;
const proc = Bun.spawn(["script", "-q", "/dev/null", "-c", command], {
cwd: ws.data.cwd,
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
COLUMNS: String(ws.data.cols),
LINES: String(ws.data.rows),
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
});
ws.data.proc = proc;
void (async () => {
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
ws.send(decoder.decode(chunk.value, { stream: true }));
}
} catch {
// Client closed.
}
})();
void (async () => {
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
ws.send(decoder.decode(chunk.value, { stream: true }));
}
} catch {
// Client closed.
}
})();
proc.exited.then((code) => {
try {
ws.send(`\r\n[terminal exited: ${code ?? "unknown"}]\r\n`);
ws.close();
} catch {
// Client closed.
}
}).catch(() => undefined);
},
message(ws: ServerWebSocket<TerminalSocketData>, message: string | Buffer) {
const stdin = ws.data.proc?.stdin as { write?: (data: string | Buffer) => unknown } | undefined;
stdin?.write?.(message);
},
close(ws: ServerWebSocket<TerminalSocketData>) {
ws.data.proc?.kill();
},
};
+386
View File
@@ -0,0 +1,386 @@
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { serveStatic } from "hono/bun";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { getConfigSummary, loadConfig, saveConfigPatch } from "./config.ts";
import { CameraManager } from "./camera.ts";
import { createWorkspaceApi } from "./workspace.ts";
import { createModuleApi, handleModuleTerminalUpgrade, moduleTerminalWebSocket } from "./modules.ts";
import { createDisplayApi } from "./display.ts";
import { createKasmVncApi } from "./kasmVnc.ts";
import { createBridgeApi, createViewerControlApi } from "./bridge.ts";
import { createVisualizationApi } from "./visualization.ts";
const config = loadConfig();
const app = new Hono();
const camera = new CameraManager(config);
const AUTHORIZED_KEYS_BEGIN = "# BEGIN AUTONOMY STUDIO MANAGED KEYS";
const AUTHORIZED_KEYS_END = "# END AUTONOMY STUDIO MANAGED KEYS";
function kasmVncUrl(requestUrl: string): string {
const hostname = new URL(requestUrl).hostname;
const protocol = config.desktop.protocol;
const port = config.desktop.webPort;
return `${protocol}://${hostname}:${port}`;
}
function bearerToken(headerValue: string | undefined): string {
const value = headerValue?.trim() ?? "";
if (!value) return "";
const [type, token] = value.split(/\s+/, 2);
return type?.toLowerCase() === "bearer" ? token ?? "" : value;
}
function normalizePublicSshKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) return null;
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) {
return null;
}
return normalized;
}
function resolveSshHome(sshUser?: string | null): string {
const user = sshUser?.trim();
const currentUser = process.env.AGENT_SSH_USER || process.env.USER || "";
if (!user || user === currentUser) return process.env.HOME || "/root";
if (user === "root") return "/root";
return `/home/${user}`;
}
async function syncAuthorizedKeys(publicKeys: string[], sshUser?: string | null): Promise<{ installed: number; path: string }> {
const home = resolveSshHome(sshUser);
const sshDir = join(home, ".ssh");
const authorizedKeysPath = join(sshDir, "authorized_keys");
const uniqueKeys = Array.from(new Set(publicKeys.map(normalizePublicSshKey).filter((key): key is string => Boolean(key))));
await mkdir(sshDir, { recursive: true, mode: 0o700 });
const current = await readFile(authorizedKeysPath, "utf8").catch(() => "");
const block = [
AUTHORIZED_KEYS_BEGIN,
...uniqueKeys.map((key) => `${key} autonomy-studio`),
AUTHORIZED_KEYS_END,
].join("\n");
const blockPattern = new RegExp(`\\n?${AUTHORIZED_KEYS_BEGIN}[\\s\\S]*?${AUTHORIZED_KEYS_END}\\n?`, "m");
const base = current.replace(blockPattern, "").trimEnd();
const next = [base, block].filter(Boolean).join("\n\n") + "\n";
await writeFile(authorizedKeysPath, next, { mode: 0o600 });
await chmod(sshDir, 0o700);
await chmod(authorizedKeysPath, 0o600);
return { installed: uniqueKeys.length, path: authorizedKeysPath };
}
function isProtectedPath(pathname: string): boolean {
if (pathname === "/health") return false;
if (pathname.startsWith("/api/")) return true;
if (pathname.startsWith("/visualization/")) return true;
return ["/plan", "/drive", "/clear", "/stop", "/reset"].includes(pathname);
}
app.use(logger());
app.use("*", cors({
origin: "*",
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}));
app.use("/static/*", serveStatic({ root: "./public" }));
app.get("/vendor/gstwebrtc-api.js", () => {
return new Response(Bun.file("node_modules/@tomoxv/gstwebrtc-api/dist/gstwebrtc-api-2.0.0.min.js"), {
headers: { "content-type": "application/javascript; charset=utf-8" },
});
});
app.use("*", async (c, next) => {
const frameAncestors = config.iframe.allowOrigins.join(" ");
c.header("X-Content-Type-Options", "nosniff");
c.header("Referrer-Policy", "no-referrer");
c.header("Content-Security-Policy", `frame-ancestors 'self' ${frameAncestors}`);
await next();
});
app.use("*", async (c, next) => {
const gatewayToken = config.security.gatewayToken.trim();
if (!gatewayToken || !isProtectedPath(new URL(c.req.url).pathname)) {
await next();
return;
}
const providedToken =
c.req.header("x-agent-gateway-token")?.trim() ||
bearerToken(c.req.header("authorization"));
if (providedToken !== gatewayToken) {
return c.json({ ok: false, message: "invalid_agent_gateway_token" }, 401);
}
await next();
});
app.get("/styles.css", () => {
return new Response(Bun.file("public/styles.css"), {
headers: { "content-type": "text/css; charset=utf-8" },
});
});
app.get("/camera.js", () => {
return new Response(Bun.file("public/camera.js"), {
headers: { "content-type": "application/javascript; charset=utf-8" },
});
});
app.get("/app.js", () => {
return new Response(Bun.file("public/app.js"), {
headers: { "content-type": "application/javascript; charset=utf-8" },
});
});
app.get("/api/camera/mjpeg", (c) => {
camera.stop();
const url = new URL(c.req.url);
const device = url.searchParams.get("device") || config.camera.videoDevice;
const width = Number(url.searchParams.get("width") || config.camera.width);
const height = Number(url.searchParams.get("height") || config.camera.height);
const framerate = Number(url.searchParams.get("framerate") || config.camera.framerate);
const proc = Bun.spawn([
"gst-launch-1.0",
"-q",
"v4l2src",
`device=${device}`,
"do-timestamp=true",
"!",
`video/x-raw,format=UYVY,width=${width},height=${height},framerate=${framerate}/1`,
"!",
"videoconvert",
"!",
"jpegenc",
"quality=70",
"!",
"multipartmux",
"boundary=frame",
"!",
"fdsink",
"fd=1",
], {
stdout: "pipe",
stderr: "pipe",
});
void new Response(proc.stderr).text().then((message) => {
if (message.trim()) console.warn(`[camera-mjpeg] ${message.trim()}`);
}).catch(() => undefined);
c.req.raw.signal.addEventListener("abort", () => {
proc.kill();
}, { once: true });
return new Response(proc.stdout, {
headers: {
"content-type": "multipart/x-mixed-replace; boundary=frame",
"cache-control": "no-store",
"x-accel-buffering": "no",
},
});
});
app.get("/", (c) => {
return c.html(
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Platform Agent</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="admin-shell">
<header class="admin-header">
<div>
<h1>Platform Agent</h1>
<p>Manage communication, remote desktop, ROS bridge, camera, and module runtime settings for Jetson or Mobility PC devices.</p>
</div>
<nav>
<a href="/remote" target="_blank">Remote</a>
<a href="/camera" target="_blank">Camera</a>
</nav>
</header>
<section class="status-grid" data-status-grid />
<form class="config-form" data-config-form>
<section>
<h2>Server</h2>
<label>Host<input name="server.host" /></label>
<label>Port<input name="server.port" type="number" min="1" max="65535" /></label>
<label>Public URL<input name="server.publicBaseUrl" /></label>
</section>
<section>
<h2>Workspace</h2>
<label>Root<input name="workspace.root" /></label>
<label>Max file size<input name="workspace.maxFileSize" type="number" min="1" /></label>
</section>
<section>
<h2>Remote Desktop</h2>
<label>Display<input name="desktop.display" /></label>
<label>Web port<input name="desktop.webPort" type="number" min="1" max="65535" /></label>
<label>Geometry<input name="desktop.geometry" /></label>
<label>Desktop<input name="desktop.desktop" /></label>
</section>
<section>
<h2>Camera</h2>
<label class="inline"><input name="camera.enabled" type="checkbox" /> Enabled</label>
<label>Device<input name="camera.videoDevice" /></label>
<label>Signaling URL<input name="camera.signalingUrl" /></label>
<label>Room ID<input name="camera.roomId" /></label>
<label>Width<input name="camera.width" type="number" min="1" /></label>
<label>Height<input name="camera.height" type="number" min="1" /></label>
<label>Framerate<input name="camera.framerate" type="number" min="1" /></label>
<label>Bitrate<input name="camera.bitrate" type="number" min="1" /></label>
</section>
<section>
<h2>ROS Bridge</h2>
<label>Stack<select name="ros.stack"><option value="autoware">Autoware</option><option value="nav2">Nav2</option></select></label>
<label>ROS_DOMAIN_ID<input name="ros.domainId" inputmode="numeric" /></label>
<label>Bridge URL<input name="ros.bridgeUrl" /></label>
<label>Route input<select name="ros.routeInput"><option value="rviz_topic">rviz_topic</option><option value="service">service</option></select></label>
<label>Route prefix<input name="ros.routePrefix" /></label>
<label>Map point stride<input name="ros.mapPointStride" type="number" min="1" /></label>
<label>Map point limit<input name="ros.mapPointLimit" type="number" min="1000" /></label>
</section>
<section>
<h2>Module Scripts</h2>
<label>Autoware script<input name="modules.autowareRunScript" /></label>
<label>Nav2 script<input name="modules.nav2RunScript" /></label>
</section>
<footer>
<button type="button" data-refresh>Refresh</button>
<button type="submit">Save config</button>
<span data-message></span>
</footer>
</form>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>,
);
});
app.get("/remote", (c) => {
const frameSrc = kasmVncUrl(c.req.url);
return c.html(
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Platform Agent Remote Desktop</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<iframe
title="KasmVNC Remote Desktop"
src={frameSrc}
referrerPolicy="no-referrer"
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
/>
</main>
</body>
</html>,
);
});
app.get("/camera", (c) => {
return c.html(
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{config.camera.streamName} Camera</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="camera-shell">
<video autoplay playsinline muted data-video />
<img alt="camera stream" data-mjpeg />
<section class="camera-panel">
<strong data-state>initializing</strong>
<div>
<select data-mode>
<option value="mjpeg">MJPEG</option>
<option value="webrtc">WebRTC</option>
</select>
<button type="button" data-start>Start</button>
<button type="button" data-stop>Stop</button>
</div>
<pre data-logs />
</section>
</main>
<script type="module" src="/camera.js"></script>
</body>
</html>,
);
});
app.get("/health", (c) => c.json({
ok: true,
sshUser: process.env.AGENT_SSH_USER || process.env.USER || null,
workspaceRoot: config.workspace.root,
}));
app.post("/api/ssh/authorized-keys", async (c) => {
const body = await c.req.json().catch(() => ({}));
const keys = Array.isArray(body?.publicKeys) ? body.publicKeys.map(String) : [];
const sshUser = typeof body?.sshUser === "string" ? body.sshUser : null;
const result = await syncAuthorizedKeys(keys, sshUser);
return c.json({ ok: true, ...result });
});
app.get("/api/config", (c) => c.json(getConfigSummary(config)));
app.post("/api/config", async (c) => {
const body = await c.req.json().catch(() => ({}));
return c.json(getConfigSummary(await saveConfigPatch(config, body)));
});
app.put("/api/config", async (c) => {
const body = await c.req.json().catch(() => ({}));
return c.json(getConfigSummary(await saveConfigPatch(config, body)));
});
app.get("/api/camera/status", (c) => c.json(camera.status()));
app.get("/api/camera/devices", async (c) => c.json(await camera.devices()));
app.post("/api/camera/start", async (c) => {
const body = await c.req.json().catch(() => ({}));
return c.json(camera.start(body));
});
app.post("/api/camera/stop", (c) => c.json(camera.stop()));
app.route("/api/workspace", createWorkspaceApi(config));
app.route("/api/kasmvnc", createKasmVncApi(config));
app.route("/api/display", createDisplayApi());
app.route("/api/modules", createModuleApi(config));
app.route("/api/bridge", createBridgeApi(config));
app.route("/visualization", createVisualizationApi(config));
app.route("/", createViewerControlApi(config));
Bun.serve({
hostname: config.server.host,
port: config.server.port,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/api/modules/terminal") return handleModuleTerminalUpgrade(config, req, server as never);
return app.fetch(req, server);
},
websocket: {
open(ws) {
moduleTerminalWebSocket.open(ws as never);
},
message(ws, message) {
moduleTerminalWebSocket.message(ws as never, message);
},
close(ws) {
moduleTerminalWebSocket.close(ws as never);
},
},
});
console.log(`Agent listening on ${config.server.publicBaseUrl}`);
console.log(`Embedding KasmVNC: ${kasmVncUrl(config.server.publicBaseUrl)}`);
+149
View File
@@ -0,0 +1,149 @@
import { Hono } from "hono";
import type { AgentConfig } from "./config.ts";
import { startModule, statusList, stopModule } from "./modules.ts";
type VisualizationStack = "autoware" | "nav2";
type VisualizationProfile = {
type?: string;
bridge?: {
image?: string;
};
};
type VisualizationStartBody = {
stack?: string;
rosDomainId?: string;
profile?: VisualizationProfile;
};
function normalizeStack(value?: string | null): VisualizationStack {
const stack = String(value ?? "").trim().toLowerCase();
return stack === "nav2" || stack === "slam" || stack === "real_slam" || stack === "real_navigation"
? "nav2"
: "autoware";
}
function normalizeRosDomainId(value?: string | null): string {
const trimmed = String(value ?? "").trim();
return /^\d+$/.test(trimmed) ? trimmed : "0";
}
function bridgeFolderName(stack: VisualizationStack): string {
return stack === "nav2" ? "__bridge_nav2" : "__bridge_autoware";
}
function bridgeContainerKey(stack: VisualizationStack): string {
return stack === "nav2" ? "nav2-ros-bridge" : "autoware-ros-bridge";
}
function applyProfileToConfig(config: AgentConfig, stack: VisualizationStack, body: VisualizationStartBody): void {
config.ros.stack = stack;
config.ros.domainId = normalizeRosDomainId(body.rosDomainId ?? config.ros.domainId);
const bridgeImage = body.profile?.bridge?.image?.trim();
if (!bridgeImage) return;
if (stack === "nav2") config.ros.nav2Image = bridgeImage;
else config.ros.autowareImage = bridgeImage;
}
async function readBridgeStatus(config: AgentConfig, requestedStack?: VisualizationStack) {
const statuses = await statusList(config);
const autoware = statuses.find((item) => item.containerKey === "autoware-ros-bridge");
const nav2 = statuses.find((item) => item.containerKey === "nav2-ros-bridge");
const runningStack = nav2?.running ? "nav2" : autoware?.running ? "autoware" : null;
const stack = runningStack ?? requestedStack ?? config.ros.stack;
return {
ok: true,
running: Boolean(runningStack),
stack,
configured: {
stack: config.ros.stack,
domainId: config.ros.domainId,
bridgeUrl: config.ros.bridgeUrl,
autowareImage: config.ros.autowareImage,
nav2Image: config.ros.nav2Image,
},
containers: {
autoware: autoware ? { running: autoware.running, status: autoware.status } : null,
nav2: nav2 ? { running: nav2.running, status: nav2.status } : null,
},
};
}
export function createVisualizationApi(config: AgentConfig): Hono {
const app = new Hono();
app.get("/status", async (c) => {
try {
return c.json(await readBridgeStatus(config));
} catch (error) {
return c.json({
ok: false,
running: false,
stack: null,
message: error instanceof Error ? error.message : String(error),
}, 502);
}
});
app.post("/start", async (c) => {
try {
const body = await c.req.json<VisualizationStartBody>().catch(() => ({} as VisualizationStartBody));
const stack = normalizeStack(body.stack ?? body.profile?.type ?? config.ros.stack);
applyProfileToConfig(config, stack, body);
const result = await startModule(config, bridgeFolderName(stack), config.ros.domainId);
if (!result.ok) {
return c.json({
ok: false,
running: false,
stack,
message: result.message || "Failed to start visualization bridge.",
}, 502);
}
return c.json({
...(await readBridgeStatus(config, stack)),
running: true,
stack,
message: result.message,
});
} catch (error) {
return c.json({
ok: false,
running: false,
stack: null,
message: error instanceof Error ? error.message : String(error),
}, 502);
}
});
app.post("/stop", async (c) => {
try {
const body = await c.req.json<VisualizationStartBody>().catch(() => ({} as VisualizationStartBody));
const stack = normalizeStack(body.stack ?? body.profile?.type ?? config.ros.stack);
const targets = new Set(["autoware-ros-bridge", "nav2-ros-bridge", bridgeContainerKey(stack)]);
for (const containerKey of targets) {
await stopModule(config, containerKey).catch(() => ({ ok: false }));
}
return c.json({
ok: true,
running: false,
stack: null,
});
} catch (error) {
return c.json({
ok: false,
running: false,
stack: null,
message: error instanceof Error ? error.message : String(error),
}, 502);
}
});
return app;
}
+295
View File
@@ -0,0 +1,295 @@
import { Hono } from "hono";
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { basename, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
import { homedir } from "node:os";
import type { AgentConfig } from "./config.ts";
interface WorkspaceEntry {
name: string;
path: string;
type: "file" | "directory";
size: number;
modifiedAt: string;
}
const moduleNamePattern = /^\[[^\]]+\](.+)_v[^/]+_ws$/;
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
function getWorkspaceRoot(config: AgentConfig): string {
return resolve(expandHome(config.workspace.root));
}
function normalizeRequestPath(path: string): string {
const normalized = normalize(path || ".").replace(/^(\.\.(\/|\\|$))+/, "");
return normalized === "." ? "" : normalized;
}
function resolveWorkspacePath(config: AgentConfig, requestPath: string): string {
const root = getWorkspaceRoot(config);
const normalizedPath = normalizeRequestPath(requestPath);
const absolutePath = isAbsolute(normalizedPath) ? normalize(normalizedPath) : resolve(root, normalizedPath);
const relativePath = relative(root, absolutePath);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
throw new Error("Workspace path is outside of root.");
}
return absolutePath;
}
function toRelativePath(config: AgentConfig, absolutePath: string): string {
return relative(getWorkspaceRoot(config), absolutePath).split(sep).join("/");
}
function isEditableFile(config: AgentConfig, filePath: string): boolean {
const extension = extname(filePath).toLowerCase();
return extension.length === 0 || config.workspace.editableExtensions.includes(extension);
}
function imageMimeType(filePath: string): string | null {
const extension = extname(filePath).toLowerCase();
if (extension === ".png") return "image/png";
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".gif") return "image/gif";
if (extension === ".webp") return "image/webp";
if (extension === ".svg") return "image/svg+xml";
if (extension === ".bmp") return "image/bmp";
if (extension === ".ico") return "image/x-icon";
return null;
}
function errorJson(message: string, status = 400): Response {
return Response.json({ ok: false, message }, { status });
}
async function persistWorkspaceRoot(config: AgentConfig, root: string): Promise<void> {
const configPath = resolve(process.env.AGENT_CONFIG ?? "agent.config.json");
try {
const raw = await readFile(configPath, "utf8").catch(() => "{}");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const workspace = typeof parsed.workspace === "object" && parsed.workspace !== null && !Array.isArray(parsed.workspace)
? parsed.workspace as Record<string, unknown>
: {};
parsed.workspace = {
...workspace,
root,
};
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
} catch (error) {
console.warn("Failed to persist workspace root:", error);
}
}
async function setWorkspaceRoot(config: AgentConfig, body: { path?: string; root?: string }) {
const nextRoot = String(body.path ?? body.root ?? "").trim();
if (!nextRoot) return errorJson("path is required.");
config.workspace.root = nextRoot;
await mkdir(getWorkspaceRoot(config), { recursive: true });
await persistWorkspaceRoot(config, nextRoot);
return Response.json({
ok: true,
root: config.workspace.root,
resolvedRoot: getWorkspaceRoot(config),
});
}
async function toEntry(config: AgentConfig, absolutePath: string): Promise<WorkspaceEntry> {
const itemStat = await stat(absolutePath);
return {
name: basename(absolutePath),
path: toRelativePath(config, absolutePath),
type: itemStat.isDirectory() ? "directory" : "file",
size: itemStat.size,
modifiedAt: itemStat.mtime.toISOString(),
};
}
async function listDirectory(config: AgentConfig, requestPath: string): Promise<WorkspaceEntry[]> {
const directoryPath = resolveWorkspacePath(config, requestPath);
const directoryStat = await stat(directoryPath);
if (!directoryStat.isDirectory()) throw new Error("Path is not a directory.");
const names = await readdir(directoryPath);
const entries = await Promise.all(names.map((name) => toEntry(config, join(directoryPath, name))));
return entries.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
export function createWorkspaceApi(config: AgentConfig): Hono {
const app = new Hono();
app.post("/", async (c) => {
try {
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
}
});
app.post("/root", async (c) => {
try {
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
}
});
app.put("/root", async (c) => {
try {
return setWorkspaceRoot(config, await c.req.json<{ path?: string; root?: string }>());
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to set workspace root.", 500);
}
});
app.get("/summary", (c) => {
c.header("Content-Type", "application/json; charset=utf-8");
return c.json({
ok: true,
root: config.workspace.root,
maxFileSize: config.workspace.maxFileSize,
});
});
app.get("/modules", async (c) => {
try {
const rootEntries = await listDirectory(config, "");
const modules = rootEntries
.filter((entry) => entry.type === "directory")
.map((entry) => {
const match = moduleNamePattern.exec(entry.name);
if (!match) return null;
return {
name: match[1],
directory: entry.name,
path: entry.path,
modifiedAt: entry.modifiedAt,
};
})
.filter((entry) => entry !== null);
return c.json({ ok: true, modules });
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to list modules.", 500);
}
});
app.get("/tree", async (c) => {
try {
const path = c.req.query("path") ?? "";
return c.json({ ok: true, entries: await listDirectory(config, path) });
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to list workspace.", 500);
}
});
app.get("/file", async (c) => {
try {
const path = c.req.query("path") ?? "";
const absolutePath = resolveWorkspacePath(config, path);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) return errorJson("Path is not a file.");
if (fileStat.size > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
if (!isEditableFile(config, absolutePath)) return errorJson("File type is not editable.", 415);
return c.json({
ok: true,
path: toRelativePath(config, absolutePath),
content: await readFile(absolutePath, "utf8"),
size: fileStat.size,
modifiedAt: fileStat.mtime.toISOString(),
});
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to read file.", 500);
}
});
app.get("/image", async (c) => {
try {
const path = c.req.query("path") ?? "";
const absolutePath = resolveWorkspacePath(config, path);
const fileStat = await stat(absolutePath);
const mime = imageMimeType(absolutePath);
if (!fileStat.isFile()) return errorJson("Path is not a file.");
if (!mime) return errorJson("File type is not a supported image.", 415);
if (fileStat.size > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
const data = await readFile(absolutePath);
return c.json({
ok: true,
name: basename(absolutePath),
path: toRelativePath(config, absolutePath),
mime,
size: fileStat.size,
dataUrl: `data:${mime};base64,${Buffer.from(data).toString("base64")}`,
});
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to read image.", 500);
}
});
app.post("/file", async (c) => {
try {
const body = await c.req.json<{ path?: string; content?: string }>();
const path = body.path ?? "";
const content = body.content ?? "";
const byteLength = Buffer.byteLength(content, "utf8");
const absolutePath = resolveWorkspacePath(config, path);
if (!path) return errorJson("path is required.");
if (byteLength > config.workspace.maxFileSize) return errorJson("File is too large.", 413);
if (!isEditableFile(config, absolutePath)) return errorJson("File type is not editable.", 415);
await mkdir(resolve(absolutePath, ".."), { recursive: true });
await writeFile(absolutePath, content, "utf8");
return c.json({ ok: true, entry: await toEntry(config, absolutePath) });
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to save file.", 500);
}
});
app.post("/folder", async (c) => {
try {
const body = await c.req.json<{ path?: string }>();
const path = body.path ?? "";
if (!path) return errorJson("path is required.");
const absolutePath = resolveWorkspacePath(config, path);
await mkdir(absolutePath, { recursive: true });
return c.json({ ok: true, entry: await toEntry(config, absolutePath) });
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to create folder.", 500);
}
});
app.delete("/file", async (c) => {
try {
const path = c.req.query("path") ?? "";
if (!path) return errorJson("path is required.");
const absolutePath = resolveWorkspacePath(config, path);
await rm(absolutePath, { recursive: true, force: false });
return c.json({ ok: true });
} catch (error) {
return errorJson(error instanceof Error ? error.message : "Failed to delete file.", 500);
}
});
return app;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"types": ["bun-types"],
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}